feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+221
View File
@@ -0,0 +1,221 @@
//! Chat transcript panel rendering — tight inline log style.
//!
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense,
//! log-like transcript: each non-tool message gets a one-line
//! `{role} {time} {content}` header with wrapped continuation lines
//! aligned under the content column; `Role::Tool` messages render as a
//! dim `↳`-prefixed sub-line attached to whatever came before.
use super::theme::Theme;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
use zesdex_domain::core::Role;
const PREFIX_WIDTH: usize = 15;
fn role_accent_color(role: &Role) -> Color {
match role {
Role::User => Theme::ROLE_USER,
Role::Assistant => Theme::ROLE_ASSISTANT,
Role::System => Theme::ROLE_SYSTEM,
Role::Tool => Theme::ROLE_TOOL,
}
}
fn format_role_label(role: &Role) -> &'static str {
match role {
Role::User => "👤 you ",
Role::Assistant => "🤖 ai ",
Role::System => "💻 sys ",
Role::Tool => "🔧 tool",
}
}
fn format_timestamp(ts: i64) -> String {
if ts <= 0 {
return String::new();
}
let secs = ts / 1000;
let mins = (secs / 60) % 60;
let hrs = (secs / 3600) % 24;
format!("{hrs:02}:{mins:02}")
}
/// Render the scrollable chat transcript panel in tight inline-log style.
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset;
let max_visible = (area.height as usize).saturating_sub(3);
let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2);
let mut display_lines: Vec<Line> = Vec::new();
for msg in messages {
if msg.role == Role::Tool {
let content = if msg.content.trim().is_empty() {
"(tool execution)".to_string()
} else {
msg.content.clone()
};
let dim = Style::default().fg(Theme::TEXT_DIM);
let content_spans = super::markdown::render_markdown(&content, content_width, true);
let content_lines = split_spans_into_lines(content_spans);
let mut lines_iter = content_lines.into_iter();
let first_spans = lines_iter.next().map_or_else(Vec::new, |line| line.spans);
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("", dim)];
spans.extend(first_spans);
display_lines.push(Line::from(spans));
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
display_lines.push(Line::from(spans));
}
continue;
}
let accent = role_accent_color(&msg.role);
let label = format_role_label(&msg.role);
let ts_str = format_timestamp(msg.timestamp);
let header_prefix = vec![
Span::styled(
format!("{label} "),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{ts_str:<5} "),
Style::default().fg(Theme::TEXT_DIM),
),
];
let content_str = if msg.content.trim().is_empty() {
"(tool execution)".to_string()
} else {
msg.content.clone()
};
let content_spans = super::markdown::render_markdown(&content_str, content_width, false);
let content_lines = split_spans_into_lines(content_spans);
let mut lines_iter = content_lines.into_iter();
if let Some(first) = lines_iter.next() {
let mut spans = header_prefix;
spans.extend(first.spans);
display_lines.push(Line::from(spans));
} else {
display_lines.push(Line::from(header_prefix));
}
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
display_lines.push(Line::from(spans));
}
}
// Streaming indicator
if state.turn_in_flight() {
let spinner_frames = ["", "", "", "", "", "", "", "", "", ""];
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(
format!("{} ", format_role_label(&Role::Assistant)),
Style::default()
.fg(Theme::ROLE_ASSISTANT)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
Span::styled(
"generating...",
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::ITALIC),
),
]));
}
// Scrolling
let title = if messages.is_empty() {
String::from(" 💬 Chat ")
} else {
format!(" 💬 Chat [{} msgs] ", messages.len())
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
title,
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::BOLD),
));
let total = display_lines.len();
let max_offset = total.saturating_sub(max_visible);
let offset = scroll_offset.min(max_offset);
let end_idx = total.saturating_sub(offset);
let start_idx = end_idx.saturating_sub(max_visible);
let visible: Vec<Line> = if start_idx < end_idx && start_idx < total {
display_lines[start_idx..end_idx].to_vec()
} else {
display_lines[total.saturating_sub(max_visible)..total].to_vec()
};
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_type(BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
scroll_title,
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::BOLD),
))
} else {
block
};
let paragraph = Paragraph::new(visible)
.block(block)
.style(Style::default().bg(Theme::BG));
frame.render_widget(paragraph, area);
}
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
let mut lines = Vec::new();
let mut current_spans = Vec::new();
for span in spans {
let text = span.content.as_ref();
let mut parts = text.split('\n').peekable();
while let Some(part) = parts.next() {
if !part.is_empty() {
current_spans.push(Span::styled(part.to_string(), span.style));
}
if parts.peek().is_some() {
lines.push(Line::from(std::mem::take(&mut current_spans)));
}
}
}
if !current_spans.is_empty() {
lines.push(Line::from(current_spans));
}
if lines.is_empty() {
lines.push(Line::from(vec![]));
}
lines
}
+455
View File
@@ -0,0 +1,455 @@
//! Markdown-to-styled-spans rendering for the chat transcript.
//!
//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and
//! translates each markdown construct into styled `ratatui::text::Span`s,
//! then re-wraps the flat span list to a target column width.
use super::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
/// Apply the "tool output" dim/italic style, or pass `style` through
/// unchanged, depending on `dim`.
fn apply_dim(style: Style, dim: bool) -> Style {
if dim {
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC)
} else {
style
}
}
/// Classify a single line inside a ` ```diff ` fenced block by its unified-diff
/// prefix, returning the color it should always render with.
fn diff_line_style(line: &str) -> Option<Style> {
if line.starts_with("@@") {
Some(Style::default().fg(Theme::INFO).bg(Theme::CODE_BG))
} else if line.starts_with('+') && !line.starts_with("+++") {
Some(Style::default().fg(Theme::SUCCESS).bg(Theme::CODE_BG))
} else if line.starts_with('-') && !line.starts_with("---") {
Some(Style::default().fg(Theme::ERROR).bg(Theme::CODE_BG))
} else {
None
}
}
/// 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 →
/// if `width > 0`, a second pass wraps long lines.
///
/// `dim`: when `true`, every span falls back to `Theme::TEXT_DIM` + italic
/// (the "tool output" look) *except* lines inside a ` ```diff ` fenced
/// block, which always keep their +/-/@@ diff color regardless of `dim`.
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
let parser = pulldown_cmark::Parser::new_ext(text, options);
let mut in_code_block = false;
let mut in_diff_block = false;
let mut in_heading = false;
let mut heading_level = 0;
let mut in_table_cell = false;
let mut table_rows: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
let mut current_row: Vec<Vec<Span<'static>>> = Vec::new();
let mut current_cell: Vec<Span<'static>> = Vec::new();
for event in parser {
match event {
pulldown_cmark::Event::Start(tag) => {
match tag {
pulldown_cmark::Tag::CodeBlock(kind) => {
in_code_block = true;
in_diff_block = matches!(
&kind,
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
);
spans.push(Span::styled("\n", Style::default()));
spans.push(Span::styled(
" ┌─ code ",
apply_dim(
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
dim,
),
));
spans.push(Span::styled("\n", Style::default()));
}
pulldown_cmark::Tag::Heading { level, .. } => {
in_heading = true;
heading_level = match level {
pulldown_cmark::HeadingLevel::H1 => 1,
pulldown_cmark::HeadingLevel::H2 => 2,
pulldown_cmark::HeadingLevel::H3 => 3,
_ => 4,
};
}
pulldown_cmark::Tag::Item => {
spans.push(Span::styled(
"",
apply_dim(Style::default().fg(Theme::PRIMARY), dim),
));
}
pulldown_cmark::Tag::Link { dest_url, .. } => {
spans.push(Span::styled(
"[",
apply_dim(Style::default().fg(Theme::INFO), dim),
));
spans.push(Span::styled(
format!("]({dest_url})"),
apply_dim(
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::ITALIC),
dim,
),
));
}
pulldown_cmark::Tag::BlockQuote(_) => {
spans.push(Span::styled(
"",
apply_dim(Style::default().fg(Theme::BLOCKQUOTE_BAR), dim),
));
}
pulldown_cmark::Tag::Table(_) => {
table_rows.clear();
}
pulldown_cmark::Tag::TableHead | pulldown_cmark::Tag::TableRow => {
current_row.clear();
}
pulldown_cmark::Tag::TableCell => {
in_table_cell = true;
current_cell.clear();
}
_ => {}
}
}
pulldown_cmark::Event::End(tag) => {
match tag {
pulldown_cmark::TagEnd::CodeBlock => {
in_code_block = false;
in_diff_block = false;
spans.push(Span::styled(
"\n └─\n",
apply_dim(
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
dim,
),
));
}
pulldown_cmark::TagEnd::Heading(_) => {
in_heading = false;
heading_level = 0;
spans.push(Span::raw("\n"));
}
pulldown_cmark::TagEnd::Paragraph => {
spans.push(Span::raw("\n\n"));
}
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
spans.push(Span::raw("\n"));
}
pulldown_cmark::TagEnd::TableCell => {
in_table_cell = false;
current_row.push(std::mem::take(&mut current_cell));
}
pulldown_cmark::TagEnd::TableHead | pulldown_cmark::TagEnd::TableRow => {
table_rows.push(std::mem::take(&mut current_row));
}
pulldown_cmark::TagEnd::Table => {
let cols_count = table_rows.first().map_or(0, std::vec::Vec::len);
if cols_count == 0 {
continue;
}
let mut col_widths = vec![0; cols_count];
for row in &table_rows {
for (i, cell) in row.iter().enumerate() {
if i < cols_count {
let cell_width: usize =
cell.iter().map(|s| s.content.chars().count()).sum();
if cell_width > col_widths[i] {
col_widths[i] = cell_width;
}
}
}
}
let effective_width = if width > 0 {
(width as usize).saturating_sub(2)
} else {
0
};
let border_overhead = cols_count * 3 + 4;
let available_width = effective_width.saturating_sub(border_overhead);
let mut total_width: usize = col_widths.iter().sum();
if width > 0 && total_width > available_width && available_width > 0 {
while total_width > available_width {
let max_idx = col_widths
.iter()
.enumerate()
.max_by_key(|&(_, &w)| w)
.map(|(i, _)| i)
.unwrap();
if col_widths[max_idx] <= 3 {
break;
}
col_widths[max_idx] -= 1;
total_width -= 1;
}
}
spans.push(Span::raw("\n"));
for (r, row) in table_rows.iter().enumerate() {
let mut cell_lines = Vec::new();
for (i, cell) in row.iter().enumerate() {
if i < cols_count {
cell_lines.push(wrap_spans_to_lines(cell, col_widths[i]));
}
}
let max_height =
cell_lines.iter().map(std::vec::Vec::len).max().unwrap_or(1);
for y in 0..max_height {
spans.push(Span::styled(
" | ",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
for (i, cl) in cell_lines.iter().enumerate() {
let line_spans =
if y < cl.len() { &cl[y] } else { [].as_slice() };
let mut line_width = 0;
for span in line_spans {
line_width += span.content.chars().count();
spans.push(span.clone());
}
let pad = col_widths[i].saturating_sub(line_width);
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(
" | ",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
}
spans.push(Span::raw("\n"));
}
if r == 0 {
spans.push(Span::styled(
" |",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
for w in &col_widths {
spans.push(Span::styled(
format!("{}-|", "-".repeat(*w + 2)),
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
}
spans.push(Span::raw("\n"));
}
}
spans.push(Span::raw("\n"));
}
_ => {}
}
}
pulldown_cmark::Event::Text(text) => {
let s = text.to_string();
if in_code_block {
if in_diff_block {
for (i, line) in s.split('\n').enumerate() {
if i > 0 {
spans.push(Span::raw("\n"));
}
if line.is_empty() {
continue;
}
let style = diff_line_style(line).unwrap_or_else(|| {
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG)
});
spans.push(Span::styled(format!(" {line}"), style));
}
} else {
let indented = format!(" {}", s.replace('\n', "\n "));
spans.push(Span::styled(
indented,
apply_dim(
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
dim,
),
));
}
} else if in_heading {
let color = match heading_level {
1 => Theme::PRIMARY,
2 => Theme::INFO,
3 => Theme::ACCENT_PURPLE,
_ => Theme::TEXT,
};
spans.push(Span::styled(
s,
apply_dim(Style::default().fg(color).add_modifier(Modifier::BOLD), dim),
));
} else if in_table_cell {
current_cell.push(Span::styled(s, apply_dim(Style::default(), dim)));
} else {
spans.push(Span::styled(s, apply_dim(Style::default(), dim)));
}
}
pulldown_cmark::Event::Code(text) => {
let span = Span::styled(
format!(" {text} "),
apply_dim(
Style::default()
.fg(Theme::ACCENT_TEAL)
.bg(Theme::CODE_BAR)
.add_modifier(Modifier::BOLD),
dim,
),
);
if in_table_cell {
current_cell.push(span);
} else {
spans.push(span);
}
}
pulldown_cmark::Event::SoftBreak => {
spans.push(Span::raw(" "));
}
pulldown_cmark::Event::HardBreak => {
spans.push(Span::raw("\n"));
}
_ => {}
}
}
if width > 0 {
let mut spans_out = Vec::new();
let mut line_len = 0;
let effective_width = (width as usize).saturating_sub(2);
for span in spans {
let style = span.style;
let text = span.content.as_ref();
let mut current = String::new();
let mut tokens = Vec::new();
for c in text.chars() {
if c == ' ' {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
tokens.push(" ".to_string());
} else if c == '\n' {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
tokens.push("\n".to_string());
} else {
current.push(c);
}
}
if !current.is_empty() {
tokens.push(current);
}
for token in tokens {
if token == "\n" {
spans_out.push(Span::styled("\n", style));
line_len = 0;
} else if token == " " {
if line_len > 0 && line_len < effective_width {
spans_out.push(Span::styled(" ", style));
line_len += 1;
}
} else {
let token_len = token.chars().count();
if line_len + token_len > effective_width && line_len > 0 {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
if token_len > effective_width {
for c in token.chars() {
if line_len >= effective_width {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
spans_out.push(Span::styled(c.to_string(), style));
line_len += 1;
}
} else {
spans_out.push(Span::styled(token, style));
line_len += token_len;
}
}
}
}
spans = spans_out;
}
spans
}
fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<Span<'static>>> {
let mut lines = Vec::new();
let mut current_line = Vec::new();
let mut line_len = 0;
for span in spans {
let style = span.style;
let text = span.content.as_ref();
let mut current_word = String::new();
let mut tokens = Vec::new();
for c in text.chars() {
if c == ' ' {
if !current_word.is_empty() {
tokens.push(current_word.clone());
current_word.clear();
}
tokens.push(" ".to_string());
} else {
current_word.push(c);
}
}
if !current_word.is_empty() {
tokens.push(current_word);
}
for token in tokens {
if token == " " {
if line_len > 0 && line_len < target_width {
current_line.push(Span::styled(" ", style));
line_len += 1;
}
} else {
let token_len = token.chars().count();
if line_len + token_len > target_width && line_len > 0 {
lines.push(std::mem::take(&mut current_line));
line_len = 0;
}
if token_len > target_width {
for c in token.chars() {
if target_width > 0 && line_len >= target_width {
lines.push(std::mem::take(&mut current_line));
line_len = 0;
}
current_line.push(Span::styled(c.to_string(), style));
line_len += 1;
}
} else {
current_line.push(Span::styled(token, style));
line_len += token_len;
}
}
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(vec![]);
}
lines
}
+241
View File
@@ -0,0 +1,241 @@
//! Top-level TUI render pipeline: layouts the terminal into chat / input
//! / status regions, dispatches overlay rendering with glassmorphism-style
//! centered panels, and floats toast notifications over the top-right corner.
pub mod chat;
pub mod markdown;
pub mod sidebar;
pub mod status;
pub mod theme;
pub mod workflow;
pub mod overlays;
use crate::state::AppStateRest;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::Frame;
use theme::Theme;
use zesdex_infrastructure::ToastKind;
const SIDEBAR_MIN_WIDTH: u16 = 90;
/// Top-level render entry point called once per TUI frame.
pub fn draw(frame: &mut Frame, state: &AppStateRest) {
let area = frame.area();
let show_sidebar = area.width > SIDEBAR_MIN_WIDTH;
let (main_area, sidebar_area) = if show_sidebar {
let has_workflow = !state.workflow_engine.agents.is_empty();
let sidebar_width = if has_workflow { 48 } else { 30 };
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(sidebar_width)])
.split(area);
(h_chunks[0], Some(h_chunks[1]))
} else {
(area, None)
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.split(main_area);
let chat_area = chunks[0];
let input_area = chunks[1];
let status_area = chunks[2];
if state.misc.overlay.is_active() {
let overlay = state.misc.overlay;
overlays::render_overlay(frame, chat_area, overlay, state);
} else {
render_main_panel(frame, chat_area, state);
}
render_input_bar(frame, input_area, state);
status::draw_status_bar(frame, status_area, state);
if let Some(sidebar_rect) = sidebar_area {
sidebar::draw_sidebar(frame, sidebar_rect, state);
}
render_toasts(frame, state);
}
fn render_main_panel(frame: &mut Frame, area: Rect, state: &AppStateRest) {
chat::draw_chat(frame, area, state);
}
fn render_input_bar(frame: &mut Frame, area: Rect, state: &AppStateRest) {
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
let n = state.input.autocomplete_candidates.len().min(10) as u16;
let dropdown_height = n + 2;
let dropdown_area = Rect {
x: area.x,
y: area.y.saturating_sub(dropdown_height),
width: area.width.min(45),
height: dropdown_height,
};
let dropdown_title = match state.input.autocomplete_kind {
crate::state::AutocompleteKind::Command => " ⌘ Commands ",
crate::state::AutocompleteKind::FileMention => " 📁 Files ",
};
let dropdown_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
dropdown_title,
Style::default().fg(Theme::PRIMARY),
))
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
let mut lines: Vec<Line> = Vec::new();
let selected = state.input.autocomplete_idx;
for (i, candidate) in state
.input
.autocomplete_candidates
.iter()
.enumerate()
.take(10)
{
let prefix = if i == selected { "" } else { " " };
let style = if i == selected {
Style::default()
.fg(Theme::TEXT)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
};
let label = format!("{prefix}{candidate}");
lines.push(Line::from(Span::styled(label, style)));
}
let dropdown = Paragraph::new(lines).block(dropdown_block);
frame.render_widget(dropdown, dropdown_area);
}
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::SURFACE));
let input_text = &state.input.buffer;
let cursor_pos = state.input.cursor;
let prompt = Span::styled(
" ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
);
let mut spans = vec![prompt];
if input_text.is_empty() {
spans.push(Span::styled(
"Type a message or /command...",
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
));
} else {
let (before, after) = input_text.split_at(cursor_pos);
spans.push(Span::raw(before.to_string()));
let cursor_char = if after.is_empty() { " " } else { &after[..1] };
spans.push(Span::styled(
cursor_char,
Style::default()
.bg(Theme::HIGHLIGHT)
.fg(Theme::BG)
.add_modifier(Modifier::BOLD),
));
if after.len() > 1 {
spans.push(Span::raw(after[1..].to_string()));
}
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
let now_ms = chrono::Utc::now().timestamp_millis();
let active: Vec<&zesdex_infrastructure::Toast> = state
.misc
.toasts
.iter()
.filter(|t| !t.expired(now_ms))
.collect();
if active.is_empty() {
return;
}
let area = frame.area();
let toast_w: u16 = 48;
let x = area.width.saturating_sub(toast_w).saturating_sub(2);
let mut y: u16 = 1;
for toast in active.iter().rev().take(4) {
let line_count = toast.message.lines().count().max(1) as u16;
let h = line_count + 2;
let toast_area = Rect {
x,
y,
width: toast_w,
height: h,
};
if toast_area.bottom() > area.height {
break;
}
frame.render_widget(Clear, toast_area);
let (border_color, icon) = match toast.kind {
ToastKind::Success => (Theme::SUCCESS, ""),
ToastKind::Warning => (Theme::WARNING, ""),
ToastKind::Error => (Theme::ERROR, ""),
ToastKind::Info => (Theme::INFO, " "),
ToastKind::Lesson => (Theme::ACCENT_PURPLE, " 📘 "),
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(Span::styled(icon, Style::default().fg(border_color)))
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
let paragraph = Paragraph::new(toast.message.as_str())
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, toast_area);
y = y.saturating_add(h).saturating_add(1);
}
}
/// Split `items` into the slice that fits within `max_visible` entries and
/// the count of items hidden beyond that limit.
pub(crate) fn split_for_display<T>(items: &[T], max_visible: usize) -> (&[T], usize) {
if items.len() <= max_visible {
(items, 0)
} else {
(&items[..max_visible], items.len() - max_visible)
}
}
/// Build the dim trailing hint line a sidebar widget shows when its
/// content is truncated.
pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> {
Line::from(Span::styled(
format!(" +{hidden} more — {command}"),
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
))
}
@@ -0,0 +1,46 @@
//! Overlay: list of active / completed bash background jobs.
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Bash Jobs overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = super::overlay_block(block, "Bash Jobs", Theme::ACCENT_ORANGE);
let lines: Vec<Line> = state
.session_runtime
.as_ref()
.map(|r| {
r.bash_jobs
.iter()
.map(|job| {
Line::from(Span::styled(
format!(
" [{}] {}{}",
job.id,
job.command,
if job.running { "running" } else { "done" },
),
Style::default().fg(Theme::TEXT),
))
})
.collect()
})
.unwrap_or_default();
let paragraph = if lines.is_empty() {
Paragraph::new(Line::from(Span::styled(
" No active bash jobs.",
Style::default().fg(Theme::TEXT_DIM),
)))
.block(block)
} else {
Paragraph::new(lines).block(block)
};
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,36 @@
//! Overlay: confirm-before-clear dialog for the chat transcript.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Clear Transcript confirmation dialog.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
_state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Clear Transcript ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let lines = vec![
Line::from(Span::styled(
" Clear all messages from the transcript?",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Enter to confirm · Esc to cancel",
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,52 @@
//! Overlay: inline editor mode — shows the current input buffer with cursor
//! position and save/dismiss key hints.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Editor overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Editor ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::PRIMARY));
let lines = vec![
Line::from(Span::styled(
" Editor Mode — Ctrl+S save, Esc dismiss",
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::ITALIC),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Buffer:",
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" {}", state.input.buffer),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(
" Cursor: pos {} / {}",
state.input.cursor,
state.input.buffer.len()
),
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,52 @@
//! Overlay: effort-level selector — lets the user pick a reasoning/quality tier.
use crate::state::{current_effort, EFFORT_LEVELS};
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
/// Render the Effort Level overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Effort Level ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let levels = EFFORT_LEVELS;
let current_idx = current_effort(state);
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
" Use ↑↓ to change effort level",
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::raw("")),
];
for (i, l) in levels.iter().enumerate() {
let selected = i == current_idx;
lines.push(Line::from(Span::styled(
if selected {
format!("{l} (active)")
} else {
format!(" {l}")
},
if selected {
Style::default()
.fg(Theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
},
)));
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,21 @@
//! Overlay: keyboard shortcut reference.
use ratatui::style::Style;
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Help overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = super::overlay_block(block, "Help", Theme::INFO);
let content = state.help_text;
let paragraph = Paragraph::new(content)
.block(block)
.style(Style::default().bg(Theme::BG))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,58 @@
//! Overlay: API key input dialog — prompts the user for a provider API key
//! with masked display (shows first 4 chars only).
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the API Key input overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" API Key ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let input_text = &state.input.buffer;
let display = if input_text.is_empty() {
" Type your API key..."
} else {
if input_text.len() > 8 {
&input_text[..4]
} else {
input_text.as_str()
}
};
let masked = if input_text.is_empty() {
display.to_string()
} else {
let suffix = if input_text.len() > 8 { "****" } else { "" };
format!("{display}{suffix}")
};
let lines = vec![
Line::from(Span::styled(
" Enter API key for authentication:",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::raw("")),
Line::from(vec![
Span::styled(" Key: ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(
masked,
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
),
]),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,203 @@
//! Overlay: Learning / lesson management — two-panel view with a scrollable
//! lesson list (left) and detail pane (right).
use crate::state::{get_learning_items, LearningItem};
use crate::view::theme::Theme;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
/// Render the Learning overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
drop(block);
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
let left_block = Block::default()
.title(Span::styled(
" Lessons ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG));
let right_block = Block::default()
.title(Span::styled(
" Details ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG));
let items = get_learning_items(state);
let mut left_lines = Vec::new();
if items.is_empty() {
left_lines.push(Line::from(Span::styled(
" No lessons found.",
Style::default().fg(Theme::TEXT_DIM),
)));
} else {
for (i, item) in items.iter().enumerate() {
let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " };
let (label, style) = match item {
LearningItem::Pending { name, .. } => (
format!("{prefix}[Pending] {name}"),
if is_selected {
Style::default()
.fg(Theme::WARNING)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::WARNING)
},
),
LearningItem::Stored { name, lifecycle, .. } => {
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
(
format!("{prefix}[{status}] {name}"),
if is_selected {
Style::default()
.fg(Theme::TEXT)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
},
)
}
};
left_lines.push(Line::from(Span::styled(label, style)));
}
}
let max_lines = h_chunks[0].height.saturating_sub(2) as usize;
let selected = state.misc.selected_index;
let start_idx = if selected >= max_lines {
selected - max_lines + 1
} else {
0
};
let end_idx = (start_idx + max_lines).min(left_lines.len());
let visible_lines = if left_lines.is_empty() {
Vec::new()
} else {
left_lines[start_idx..end_idx].to_vec()
};
let left_paragraph = Paragraph::new(visible_lines).block(left_block);
frame.render_widget(left_paragraph, h_chunks[0]);
let mut right_lines = Vec::new();
if let Some(item) = items.get(selected) {
match item {
LearningItem::Pending { name, content, scope, confidence } => {
right_lines.push(Line::from(Span::styled(
" Name:",
Style::default().fg(Theme::TEXT_DIM),
)));
right_lines.push(Line::from(Span::styled(
format!(" {name}"),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" Status: Pending Approval",
Style::default().fg(Theme::WARNING),
)));
right_lines.push(Line::from(Span::styled(
format!(" Scope: {scope}"),
Style::default().fg(Theme::TEXT),
)));
right_lines.push(Line::from(Span::styled(
format!(" Confidence: {confidence}"),
Style::default().fg(Theme::TEXT),
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" Content:",
Style::default().fg(Theme::TEXT_DIM),
)));
for line in content.lines() {
right_lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(Theme::TEXT),
)));
}
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" [Enter]/[a] Accept · [r]/[Del] Reject",
Style::default().fg(Theme::TEXT_DIM),
)));
}
LearningItem::Stored { name, content, lifecycle, scope, description } => {
right_lines.push(Line::from(Span::styled(
" Name:",
Style::default().fg(Theme::TEXT_DIM),
)));
right_lines.push(Line::from(Span::styled(
format!(" {name}"),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
right_lines.push(Line::from(Span::raw("")));
let status_color = if lifecycle == "stale" { Theme::WARNING } else { Theme::SUCCESS };
right_lines.push(Line::from(Span::styled(
format!(" Status: {lifecycle}"),
Style::default().fg(status_color),
)));
right_lines.push(Line::from(Span::styled(
format!(" Scope: {scope}"),
Style::default().fg(Theme::TEXT),
)));
right_lines.push(Line::from(Span::styled(
format!(" Description: {description}"),
Style::default().fg(Theme::TEXT),
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" Content:",
Style::default().fg(Theme::TEXT_DIM),
)));
for line in content.lines() {
right_lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(Theme::TEXT),
)));
}
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" [d]/[Del] Delete Lesson",
Style::default().fg(Theme::TEXT_DIM),
)));
}
}
} else {
right_lines.push(Line::from(Span::styled(
" Select a lesson on the left.",
Style::default().fg(Theme::TEXT_DIM),
)));
}
let right_paragraph = Paragraph::new(right_lines)
.block(right_block)
.wrap(Wrap { trim: false });
frame.render_widget(right_paragraph, h_chunks[1]);
}
@@ -0,0 +1,28 @@
//! Overlay: loading / processing spinner — shown during blocking operations.
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Loading overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Loading ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let spinner = ["", "", "", "", "", "", "", "", "", ""];
let frame_idx = (state.misc.tick_count as usize) % spinner.len();
let content = format!(" {} Processing, please wait...", spinner[frame_idx]);
let paragraph = Paragraph::new(content).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,47 @@
//! Overlay: MCP (Model Context Protocol) server management.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the MCP Servers overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" MCP Servers ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::INFO));
let lines = vec![
Line::from(Span::styled(
" MCP Server Management",
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(" Session dir: {}", state.session_dir.display()),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
" No MCP servers configured.",
Style::default().fg(Theme::TEXT_MUTED),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Press Ctrl+P to configure provider settings.",
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,117 @@
//! Overlay rendering: each overlay variant gets its own module with a
//! `pub fn render(frame, area, block, state)` entry point, dispatched by
//! the top-level `render_overlay` function in this module.
pub mod bash;
pub mod clear_confirm;
pub mod editor;
pub mod effort;
pub mod help;
pub mod key_input;
pub mod learning;
pub mod loading;
pub mod mcp;
pub mod model_selector;
pub mod quit_confirm;
pub mod rewind;
pub mod settings;
pub mod todo;
pub mod usage;
use crate::state::{AppStateRest, Overlay};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Clear};
use ratatui::Frame;
use super::theme::Theme;
/// Decorate an overlay block with a styled title and matching border color.
pub fn overlay_block(block: Block<'static>, title: &str, color: ratatui::style::Color) -> Block<'static> {
block
.title(Span::styled(
format!(" {title} "),
Style::default()
.fg(color)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(color))
}
/// Compute a centered rectangle within `area` at the given percentage width and height.
pub fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2;
let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;
Rect {
x: area.x.saturating_add(x_pad),
y: area.y.saturating_add(y_pad),
width: area.width.saturating_sub(x_pad * 2).max(40),
height: area.height.saturating_sub(y_pad * 2).max(10),
}
}
/// Render the active modal overlay as a centered panel.
pub fn render_overlay(
frame: &mut Frame,
area: Rect,
overlay: Overlay,
state: &AppStateRest,
) {
let overlay_area = centered_rect(area, 75, 70);
frame.render_widget(Clear, overlay_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG));
match overlay {
Overlay::None => {}
Overlay::Help => {
help::render(frame, overlay_area, block, state);
}
Overlay::Settings => {
settings::render(frame, overlay_area, block, state);
}
Overlay::Bash => {
bash::render(frame, overlay_area, block, state);
}
Overlay::QuitConfirm => {
quit_confirm::render(frame, overlay_area, block, state);
}
Overlay::KeyInput => {
key_input::render(frame, overlay_area, block, state);
}
Overlay::Editor => {
editor::render(frame, overlay_area, block, state);
}
Overlay::Effort => {
effort::render(frame, overlay_area, block, state);
}
Overlay::Mcp => {
mcp::render(frame, overlay_area, block, state);
}
Overlay::Todo => {
todo::render(frame, overlay_area, block, state);
}
Overlay::Rewind => {
rewind::render(frame, overlay_area, block, state);
}
Overlay::Learning => {
learning::render(frame, overlay_area, block, state);
}
Overlay::Usage => {
usage::render(frame, overlay_area, block, state);
}
Overlay::Loading => {
loading::render(frame, overlay_area, block, state);
}
Overlay::ModelSelector => {
model_selector::render(frame, overlay_area, block, state);
}
Overlay::ClearConfirm => {
clear_confirm::render(frame, overlay_area, block, state);
}
}
}
@@ -0,0 +1,66 @@
//! Overlay: model/provider selector — lists available providers from config
//! and lets the user pick one with ↑/↓/Enter.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
/// Render the Model Selector overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Model Selector ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
format!(
" Current: {} / {}",
state.settings.provider, state.settings.model
),
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Providers:",
Style::default().fg(Theme::TEXT_DIM),
)),
];
let providers: Vec<(&String, &zesdex_domain::cms::ProviderConfig)> =
state.app_config.providers.iter().collect();
for (i, (name, cfg)) in providers.iter().enumerate() {
let is_current = *name == &state.settings.provider;
let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " };
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
let label = format!("{prefix}{name} ({model_str})");
let style = if is_current {
Style::default()
.fg(Theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
} else {
Style::default().fg(Theme::TEXT)
};
lines.push(Line::from(Span::styled(label, style)));
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
" ↑↓ navigate · Enter select · Esc close",
Style::default().fg(Theme::TEXT_DIM),
)));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,38 @@
//! Overlay: quit confirmation dialog.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Quit confirmation overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
_state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Quit ",
Style::default()
.fg(Theme::ERROR)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ERROR));
let lines = vec![
Line::from(Span::styled(
" Are you sure you want to quit?",
Style::default()
.fg(Theme::ERROR)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Press Enter to confirm, Esc to cancel.",
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,62 @@
//! Overlay: Rewind / session history — shows recent messages and lets the user
//! pick a point to rewind the transcript back to.
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use zesdex_domain::core::Role;
/// Render the Rewind overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Rewind ",
Style::default()
.fg(Theme::ACCENT_ORANGE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
" Use ↑↓ to navigate, Enter to rewind to that point",
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::raw("")),
];
let messages = &state.transcript_cache.messages;
if messages.is_empty() {
lines.push(Line::from(Span::styled(
" No messages in current session.",
Style::default().fg(Theme::TEXT_DIM),
)));
} else {
let start = if messages.len() > 8 { messages.len() - 8 } else { 0 };
for msg in &messages[start..] {
let role_str = match msg.role {
Role::User => "User",
Role::Assistant => "Asst",
Role::System => "Sys",
Role::Tool => "Tool",
};
let preview: String = msg.content.chars().take(70).collect();
lines.push(Line::from(Span::styled(
format!(" [{role_str}] {preview}"),
Style::default().fg(if msg.role == Role::User { Theme::INFO } else { Theme::TEXT }),
)));
}
if messages.len() > 8 {
lines.push(Line::from(Span::styled(
format!(" ... and {} more messages", messages.len() - 8),
Style::default().fg(Theme::TEXT_DIM),
)));
}
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,51 @@
//! Overlay: settings overview — displays the current provider, model,
//! max tokens, temperature, internet mode, and review toggle.
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Settings overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = super::overlay_block(block, "Settings", Theme::PRIMARY);
let lines = vec![
Line::from(Span::styled(
format!(" Provider: {}", state.settings.provider),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Model: {}", state.settings.model),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(
" Max tokens: {}",
state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())
),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(
" Temperature: {}",
state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))
),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Internet: {:?}", state.settings.internet_mode),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Review: {}", state.settings.flags.review_enabled),
Style::default().fg(Theme::TEXT),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,32 @@
//! Overlay: Tasks (todo) view — shows the full todo list content.
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
use crate::view::theme::Theme;
/// Render the Tasks / Todo overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Tasks ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let content = if state.misc.todo_content.is_empty() {
" No tasks yet."
} else {
&state.misc.todo_content
};
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
@@ -0,0 +1,98 @@
//! Overlay: usage statistics — detailed token usage, API call count, edit/
//! review/lesson activity counters, and session elapsed time.
use crate::view::sidebar::compute_usage_summary;
use crate::view::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
/// Render the Usage overlay.
pub fn render(
frame: &mut Frame,
area: ratatui::layout::Rect,
block: Block<'static>,
state: &crate::state::AppStateRest,
) {
let block = block
.title(Span::styled(
" Usage ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::INFO));
let runtime = state.session_runtime.as_ref();
let now_ms = chrono::Utc::now().timestamp_millis();
let summary = runtime.map(|r| compute_usage_summary(&r.usage, r.session_start, now_ms));
let (edit_count, lesson_count, review_count, consec_empty) =
runtime.map_or((0, 0, 0, 0), |r| {
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
});
let mut lines = vec![
Line::from(Span::styled(
" Token Usage",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
];
if let Some(s) = &summary {
lines.push(Line::from(Span::styled(
format!(" Main agent: {} tokens", s.main_tokens),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Self-learning: {} tokens", s.self_learning_tokens),
Style::default().fg(Theme::TEXT_MUTED),
)));
lines.push(Line::from(Span::styled(
format!(" Total: {} tokens", s.total_tokens),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" API calls: {}", s.api_calls),
Style::default().fg(Theme::TEXT),
)));
} else {
lines.push(Line::from(Span::styled(
" No active session.",
Style::default().fg(Theme::TEXT_DIM),
)));
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
" Activity",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" Edits: {edit_count}"),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Reviews: {review_count}"),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Lessons: {lesson_count}"),
Style::default().fg(Theme::TEXT_MUTED),
)));
lines.push(Line::from(Span::styled(
format!(" Empty reviews: {consec_empty}"),
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
)));
if let Some(s) = &summary {
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
format!(" Session: {}h {}m {}s", s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds),
Style::default().fg(Theme::TEXT_DIM),
)));
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
+168
View File
@@ -0,0 +1,168 @@
//! Persistent right-hand dashboard sidebar: Workflow, Tasks, and Usage
//! widgets stacked in three vertical thirds.
use super::theme::Theme;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage.
pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let has_workflow = !state.workflow_engine.agents.is_empty();
let constraints = if has_workflow {
vec![
Constraint::Ratio(1, 2),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
]
} else {
vec![
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
super::workflow::draw_workflow_panel(frame, chunks[0], state);
draw_tasks_widget(frame, chunks[1], state);
draw_usage_widget(frame, chunks[2], state);
}
fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let block = Block::default()
.title(Span::styled(
" Tasks ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER));
let budget = (block.inner(area).height as usize).max(1);
let content = &state.misc.todo_content;
let task_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let lines: Vec<Line> = if task_lines.is_empty() {
vec![Line::from(Span::styled(
" No tasks yet.",
Style::default().fg(Theme::TEXT_DIM),
))]
} else {
let show_hint = task_lines.len() > budget;
let item_budget = if show_hint {
budget.saturating_sub(1).max(1)
} else {
budget
};
let (visible, hidden) = super::split_for_display(&task_lines, item_budget);
let mut lines: Vec<Line> = visible
.iter()
.map(|l| {
Line::from(Span::styled(
format!(" {l}"),
Style::default().fg(Theme::TEXT),
))
})
.collect();
if show_hint {
lines.push(super::overflow_hint_line(hidden, "/todo"));
}
lines
};
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let block = Block::default()
.title(Span::styled(
" Usage ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER));
let lines: Vec<Line> = if let Some(ref rt) = state.session_runtime {
let now_ms = chrono::Utc::now().timestamp_millis();
let summary = compute_usage_summary(&rt.usage, rt.session_start, now_ms);
vec![
Line::from(Span::styled(
format!(" {:>6}: {} tok", "total", summary.total_tokens),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" {:>6}: {} tok", "main", summary.main_tokens),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" {:>6}: {} tok", "learn", summary.self_learning_tokens),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" {:>6}: {}", "calls", summary.api_calls),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(
" {:>6}: {}h {:02}m {:02}s",
"time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds
),
Style::default().fg(Theme::TEXT_DIM),
)),
]
} else {
vec![Line::from(Span::styled(
" No active session.",
Style::default().fg(Theme::TEXT_DIM),
))]
};
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
pub(crate) struct UsageSummary {
pub main_tokens: u64,
pub self_learning_tokens: u64,
pub total_tokens: u64,
pub api_calls: u64,
pub elapsed_hours: i64,
pub elapsed_minutes: i64,
pub elapsed_seconds: i64,
}
pub(crate) fn compute_usage_summary(
usage: &zesdex_domain::core::UsageStats,
session_start: i64,
now_ms: i64,
) -> UsageSummary {
let total_tokens = usage.tokens_in.saturating_add(usage.tokens_out);
let self_learning_tokens = usage.review_tokens;
let main_tokens = total_tokens.saturating_sub(self_learning_tokens);
let elapsed_ms = now_ms.saturating_sub(session_start);
let elapsed_hours = elapsed_ms / 3_600_000;
let elapsed_minutes = (elapsed_ms % 3_600_000) / 60_000;
let elapsed_seconds = (elapsed_ms % 60_000) / 1000;
UsageSummary {
main_tokens,
self_learning_tokens,
total_tokens,
api_calls: usage.api_calls,
elapsed_hours,
elapsed_minutes,
elapsed_seconds,
}
}
+115
View File
@@ -0,0 +1,115 @@
//! 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
//! bottom of the screen with three visual segments.
use super::theme::Theme;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Block;
use ratatui::Frame;
/// Render the single-line status bar.
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
let spinner_frames = ["", "", "", "", "", "", "", "", "", ""];
let (status_text, badge_bg, status_fg) = if state.turn_in_flight() {
let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()];
(format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG)
} else if state.misc.api_connected {
(" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
} else {
(" NOAPI ".to_string(), Theme::TEXT_DIM, Theme::BG)
};
let status_badge = Span::styled(
status_text,
Style::default()
.fg(status_fg)
.bg(badge_bg)
.add_modifier(Modifier::BOLD),
);
let left_spans = vec![
Span::styled(
" ⚡zesdex ",
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
),
status_badge,
];
let max_tokens = crate::state::resolve_context_window(&state.app_config, &state.settings);
let right_str = if let Some(ref rt) = state.session_runtime {
let current_tokens: usize = rt
.messages
.iter()
.filter_map(|m| m.content.as_deref())
.map(crate::state::count_tokens)
.sum();
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
));
}
parts.push(format!("{current_tokens}/{max_tokens}"));
parts.push(state.settings.provider.clone());
parts.push(state.settings.model.clone());
format!(" {} ", parts.join(" · "))
} else {
format!(
" 0/{max_tokens} · {} · {} ",
state.settings.provider, state.settings.model
)
};
let left_line = Line::from(left_spans);
let right_line = Line::from(Span::styled(
right_str,
Style::default().fg(Theme::TEXT_MUTED),
));
let center_line = if state.misc.lesson_running {
Line::from(vec![Span::styled(
" 📘 Generating Lesson... ",
Style::default()
.fg(Theme::MODE_YOLO)
.add_modifier(Modifier::BOLD),
)])
} else {
Line::from("")
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(25),
Constraint::Min(10),
Constraint::Length(60),
])
.split(area);
let block = Block::default().style(Style::default().bg(Theme::STATUS_BAR_BG).fg(Theme::TEXT));
let left_para = ratatui::widgets::Paragraph::new(left_line).block(block.clone());
frame.render_widget(left_para, chunks[0]);
let center_para = ratatui::widgets::Paragraph::new(center_line)
.block(block.clone())
.alignment(Alignment::Center);
frame.render_widget(center_para, chunks[1]);
let right_para = ratatui::widgets::Paragraph::new(right_line)
.block(block)
.alignment(Alignment::Right);
frame.render_widget(right_para, chunks[2]);
}
+56
View File
@@ -0,0 +1,56 @@
//! Central color theme for the TUI — Tokyo Night palette.
//!
//! Design: muted blue-purple dark background with desaturated blue/cyan/
//! purple accents (not neon) — the popular Tokyo Night editor/terminal
//! theme. Chosen for a calmer "professional dev tool" read.
use ratatui::style::Color;
/// Central palette of terminal colors used across all TUI render functions.
pub struct Theme;
impl Theme {
// ── Base surface colors ──────────────────────────────────────────────
pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26);
pub const SURFACE: Color = Color::Rgb(0x1f, 0x23, 0x35);
pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42);
// ── Text colors ──────────────────────────────────────────────────────
pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5);
pub const TEXT_MUTED: Color = Color::Rgb(0xa9, 0xb1, 0xd6);
pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
// ── Accent colors ────────────────────────────────────────────────────
pub const PRIMARY: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
pub const SUCCESS: Color = Color::Rgb(0x9e, 0xce, 0x6a);
pub const WARNING: Color = Color::Rgb(0xe0, 0xaf, 0x68);
pub const ERROR: Color = Color::Rgb(0xf7, 0x76, 0x8e);
pub const INFO: Color = Color::Rgb(0x7d, 0xcf, 0xff);
// ── Extended accent palette ──────────────────────────────────────────
pub const ACCENT_PURPLE: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);
pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca);
// ── Border colors ────────────────────────────────────────────────────
pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61);
// ── Role badge colors ────────────────────────────────────────────────
pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a);
pub const ROLE_ASSISTANT: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff);
pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68);
// ── Status colors ────────────────────────────────────────────────────
pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
pub const MODE_AUTO: Color = Color::Rgb(0x9e, 0xce, 0x6a);
pub const MODE_YOLO: Color = Color::Rgb(0xf7, 0x76, 0x8e);
// ── Code / markdown ──────────────────────────────────────────────────
pub const CODE_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
pub const CODE_BAR: Color = Color::Rgb(0x29, 0x2e, 0x42);
pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0x7d, 0xcf, 0xff);
// ── Misc ─────────────────────────────────────────────────────────────
pub const HIGHLIGHT: Color = Color::Rgb(0x3d, 0x59, 0xa1);
pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42);
}
+221
View File
@@ -0,0 +1,221 @@
//! Workflow status panel rendering — agent cards with state badges.
use super::theme::Theme;
use crate::state::AgentState;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
fn state_icon(state: AgentState) -> &'static str {
match state {
AgentState::Idle => "",
AgentState::Running => "",
AgentState::Completed => "",
AgentState::Failed => "",
}
}
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,
}
}
/// Render the workflow status panel.
pub fn draw_workflow_panel(
frame: &mut Frame,
area: Rect,
state: &crate::state::AppStateRest,
) {
use ratatui::layout::{Constraint, Direction, Layout};
let title = 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::BORDER))
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(4)])
.split(inner);
// Header area
let mut header_lines: Vec<Line> = Vec::new();
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)),
]));
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 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 mut card_lines: Vec<Line> = Vec::new();
for agent in &state.workflow_engine.agents {
let color = state_color(agent.state);
let icon = state_icon(agent.state);
let label = state_label(agent.state);
let duration_str = match (agent.started_at, agent.completed_at) {
(Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)),
(Some(_), None) => " (running)".to_string(),
_ => String::new(),
};
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)),
]));
if let Some(ref err) = agent.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.progress {
for line in prog.lines().take(2) {
card_lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
line.to_string(),
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
),
]));
}
}
}
let list = Paragraph::new(card_lines);
frame.render_widget(list, chunks[1]);
}
}
fn build_session_lines(state: &crate::state::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::TEXT_DIM),
)));
lines.push(Line::from(Span::raw("")));
if let Some(ref rt) = state.session_runtime {
let tool_count = rt.tool_call_results.len();
let pending = rt.pending_tool_queue.len();
let bash_count = rt.bash_jobs.len();
let msg_count = rt.messages.len();
lines.push(Line::from(vec![
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::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::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::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::TEXT_DIM),
)));
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
" The Hive is dormant. Complex tasks will stir it.",
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
)));
lines
}