Implement chat and markdown views, enhance status bar, and add workflow panel

- Added `chat.rs` for rendering chat messages with timestamps and roles.
- Introduced `markdown.rs` for rendering markdown content with styling.
- Created `status.rs` to display the application status bar with session and message counts.
- Developed `workflow.rs` to show the current workflow status, including tool calls and active jobs.
- Established a `theme.rs` for centralized color management across the UI.
- Updated `mod.rs` to include new modules and manage rendering logic.
This commit is contained in:
asepharyana
2026-07-11 13:16:10 +07:00
parent 7cb4ae6708
commit cc03bd79b6
131 changed files with 10994 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
use ratatui::layout::Rect;
use ratatui::style::{Style, Modifier};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use super::theme::Theme;
fn _role_name(role: &crate::dto::chat::message::Role) -> &'static str {
match role {
crate::dto::chat::message::Role::User => "User",
crate::dto::chat::message::Role::Assistant => "Assistant",
crate::dto::chat::message::Role::System => "System",
crate::dto::chat::message::Role::Tool => "Tool",
}
}
fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str {
match role {
crate::dto::chat::message::Role::User => "YOU",
crate::dto::chat::message::Role::Assistant => "AI ",
crate::dto::chat::message::Role::System => "SYS",
crate::dto::chat::message::Role::Tool => "TOOL",
}
}
fn format_timestamp(ts: i64) -> String {
if ts <= 0 { return "".to_string(); }
let secs = ts / 1000;
let mins = (secs / 60) % 60;
let hrs = (secs / 3600) % 24;
format!("{:02}:{:02}", hrs, mins)
}
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset;
let max_visible = (area.height as usize).saturating_sub(3);
let mut display_lines: Vec<Line> = Vec::new();
if scroll_offset > 0 {
let above = messages.len().saturating_sub(scroll_offset + max_visible).saturating_sub(1);
if above > 0 {
display_lines.push(Line::from(Span::styled(
format!("{} more messages above", above),
Style::default().fg(Theme::DIM),
)));
}
}
let iter_start = if scroll_offset + max_visible >= messages.len() {
0usize
} else {
messages.len().saturating_sub(scroll_offset + max_visible)
};
for msg in messages.iter().skip(iter_start).take(scroll_offset + max_visible) {
let role_color = match msg.role {
crate::dto::chat::message::Role::User => Theme::ROLE_USER,
crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT,
crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM,
crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL,
};
let prefix = match msg.role {
crate::dto::chat::message::Role::User => ">",
crate::dto::chat::message::Role::Assistant => "",
crate::dto::chat::message::Role::System => "#",
crate::dto::chat::message::Role::Tool => "",
};
let ts_str = format_timestamp(msg.timestamp);
let badge = role_badge(&msg.role);
let time_display = if ts_str.is_empty() { String::new() } else { format!(" [{}]", ts_str) };
let header = Line::from(vec![
Span::styled(
format!(" {} ", badge),
Style::default().fg(Theme::BG).bg(role_color).add_modifier(Modifier::BOLD),
),
Span::styled(
time_display,
Style::default().fg(Theme::DIM),
),
]);
let content_str = if msg.content.is_empty() {
"(streaming...)".to_string()
} else {
msg.content.clone()
};
let content_line = Line::from(vec![
Span::styled(format!("{} ", prefix), Style::default().fg(role_color)),
Span::styled(content_str, Style::default().fg(Theme::TEXT)),
]);
display_lines.push(header);
display_lines.push(content_line);
display_lines.push(Line::from(Span::raw("")));
}
if messages.len() > scroll_offset + max_visible {
let below = messages.len().saturating_sub(scroll_offset + max_visible);
display_lines.push(Line::from(Span::styled(
format!("{} more messages below", below),
Style::default().fg(Theme::DIM),
)));
}
let mut title = String::from(" Chat ");
if !messages.is_empty() {
title.push_str(&format!("[{} msgs]", messages.len()));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(title);
let total = display_lines.len();
let end_idx = total.saturating_sub(scroll_offset);
let start_idx = end_idx.saturating_sub(max_visible);
let end_idx = end_idx.min(total);
let visible: Vec<Line> = if start_idx < end_idx {
display_lines[start_idx..end_idx].to_vec()
} else {
Vec::new()
};
let paragraph = Paragraph::new(visible)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
+143
View File
@@ -0,0 +1,143 @@
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
pub fn render_markdown<'a>(text: &'a str, width: u16) -> Vec<Span<'a>> {
let mut spans = Vec::new();
let parser = pulldown_cmark::Parser::new(text);
let mut in_code_block = false;
for event in parser {
match event {
pulldown_cmark::Event::Start(tag) => {
match tag {
pulldown_cmark::Tag::CodeBlock(_) => {
in_code_block = true;
spans.push(Span::styled(
"```\n",
Style::default().fg(Color::DarkGray),
));
}
pulldown_cmark::Tag::Heading { level, .. } => {
let color = match level {
pulldown_cmark::HeadingLevel::H1 => Color::LightCyan,
pulldown_cmark::HeadingLevel::H2 => Color::Cyan,
_ => Color::White,
};
let prefix = match level {
pulldown_cmark::HeadingLevel::H1 => "# ",
pulldown_cmark::HeadingLevel::H2 => "## ",
pulldown_cmark::HeadingLevel::H3 => "### ",
_ => "# ",
};
spans.push(Span::styled(
prefix,
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
pulldown_cmark::Tag::Paragraph => {}
pulldown_cmark::Tag::Emphasis => {}
pulldown_cmark::Tag::Strong => {}
pulldown_cmark::Tag::List(_) => {}
pulldown_cmark::Tag::Item => {
spans.push(Span::styled(
" * ",
Style::default().fg(Color::DarkGray),
));
}
pulldown_cmark::Tag::Link { dest_url, .. } => {
spans.push(Span::styled(
"[",
Style::default().fg(Color::Cyan),
));
spans.push(Span::styled(
format!("]({})", dest_url),
Style::default().fg(Color::Blue),
));
}
pulldown_cmark::Tag::BlockQuote(_) => {
spans.push(Span::styled(
"> ",
Style::default().fg(Color::DarkGray),
));
}
_ => {}
}
}
pulldown_cmark::Event::End(tag) => {
match tag {
pulldown_cmark::TagEnd::CodeBlock => {
in_code_block = false;
spans.push(Span::styled(
"\n```\n",
Style::default().fg(Color::DarkGray),
));
}
pulldown_cmark::TagEnd::Heading(_) => {
spans.push(Span::raw("\n"));
}
pulldown_cmark::TagEnd::Paragraph => {
spans.push(Span::raw("\n\n"));
}
pulldown_cmark::TagEnd::Emphasis => {}
pulldown_cmark::TagEnd::Strong => {}
pulldown_cmark::TagEnd::List(_) => {}
pulldown_cmark::TagEnd::Item => {
spans.push(Span::raw("\n"));
}
pulldown_cmark::TagEnd::Link => {}
pulldown_cmark::TagEnd::BlockQuote(_) => {
spans.push(Span::raw("\n"));
}
_ => {}
}
}
pulldown_cmark::Event::Text(text) => {
let s = text.to_string();
if in_code_block {
spans.push(Span::styled(
s,
Style::default().fg(Color::Green),
));
} else {
spans.push(Span::raw(s));
}
}
pulldown_cmark::Event::Code(text) => {
spans.push(Span::styled(
format!("`{}`", text),
Style::default().fg(Color::Green),
));
}
pulldown_cmark::Event::SoftBreak => {
spans.push(Span::raw("\n"));
}
pulldown_cmark::Event::HardBreak => {
spans.push(Span::raw("\n"));
}
_ => {}
}
}
if width > 0 {
let mut spans_out = Vec::new();
let mut line_len = 0;
for span in &spans {
let s = span.content.clone();
let text = s.as_ref();
let remaining = text.len();
if line_len + remaining > width as usize && line_len > 0 {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
spans_out.push(Span::raw(text.to_string()));
if !text.contains('\n') {
line_len += remaining;
} else {
line_len = text.split('\n').next_back().unwrap_or("").len();
}
}
spans = spans_out;
}
spans
}
+642
View File
@@ -0,0 +1,642 @@
pub mod chat;
pub mod markdown;
pub mod status;
pub mod theme;
pub mod workflow;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Style, Modifier};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::Frame;
use theme::Theme;
pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.split(area);
let main_area = chunks[0];
let input_area = chunks[1];
let status_area = chunks[2];
if state.misc.overlay.is_active() {
let overlay = state.misc.overlay;
render_overlay(frame, main_area, overlay, state);
} else {
render_main_panel(frame, main_area, state);
}
render_input_bar(frame, input_area, state);
status::draw_status_bar(frame, status_area, state);
}
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
match state.mode {
crate::app::state::types::AgentMode::Auto
| crate::app::state::types::AgentMode::Normal
| crate::app::state::types::AgentMode::Plan
| crate::app::state::types::AgentMode::Yolo => {
chat::draw_chat(frame, area, state);
}
}
}
fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::types::Overlay, state: &crate::app::state::rest::AppStateRest) {
let overlay_area = centered_rect(area, 70, 60);
frame.render_widget(Clear, overlay_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::PRIMARY))
.style(Style::default().bg(Theme::BG));
match overlay {
crate::app::state::types::Overlay::None => {}
crate::app::state::types::Overlay::Help => {
let block = block.title(" Help ");
let content = crate::resources::HELP_TEXT;
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Settings => {
let block = block.title(" Settings ");
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),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Temperature: {:.1}", state.settings.temperature),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Internet mode: {:?}", state.settings.internet_mode),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Review enabled: {}", state.settings.review_enabled),
Style::default().fg(Theme::TEXT),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Agents => {
let block = block.title(" Agents ");
let lines = vec![
Line::from(Span::styled(
"Sub-agent management panel",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Subagent queue: {}", state.session_runtime.as_ref().map(|r| r.subagent_queue).unwrap_or(0)),
Style::default().fg(Theme::INFO),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Bash => {
let block = block.title(" Bash ");
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::DIM),
))).block(block)
} else {
Paragraph::new(lines).block(block)
};
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::QuitConfirm => {
let block = block
.title(" Quit ")
.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::WARNING).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
"Press Ctrl+Q again to confirm, Esc to cancel.",
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::SessionHub => {
let block = block.title(" Sessions ");
let lines: Vec<Line> = state.sessions.iter().map(|s| {
Line::from(Span::styled(
format!("{} | {}", s.title, s.model),
Style::default().fg(Theme::TEXT),
))
}).collect();
let paragraph = if lines.is_empty() {
Paragraph::new(Line::from(Span::styled(
"No sessions",
Style::default().fg(Theme::DIM),
))).block(block)
} else {
Paragraph::new(lines).block(block)
};
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Workflow => {
workflow::draw_workflow_panel(frame, overlay_area, state);
}
crate::app::state::types::Overlay::Onboard => {
let block = block.title(" Welcome to Zesdex ");
let content = "Press Ctrl+H for help, Ctrl+P for settings, or start typing to chat.";
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::OnboardProvider => {
let block = block.title(" Select Provider ");
let lines = vec![
Line::from(Span::styled(
"Configure AI Provider",
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
"Available providers:",
Style::default().fg(Theme::DIM),
)),
Line::from(Span::styled(
" OpenRouter - Multi-model API gateway",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
" Anthropic - Direct Claude API",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
" OpenAI - Direct GPT API",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
format!("Current: {} / {}", state.settings.provider, state.settings.model),
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled(
"Press Ctrl+P to change settings.",
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Picker => {
let block = block.title(" Select ");
let lines = vec![
Line::from(Span::styled(
"Selection Picker",
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
format!("Filter: {}", state.input.buffer),
Style::default().fg(Theme::DIM),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
"Press Enter to select, Esc to cancel.",
Style::default().fg(Theme::DIM),
)),
Line::from(Span::styled(
"Type to filter available options.",
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::KeyInput => {
let block = block.title(" Input ");
let input_text = &state.input.buffer;
let display = if input_text.is_empty() {
"Type your input..."
} else {
input_text.as_str()
};
let paragraph = Paragraph::new(display)
.block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Editor => {
let block = block.title(" Editor ");
let lines = vec![
Line::from(Span::styled(
"Editor Mode",
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
"Current input buffer:",
Style::default().fg(Theme::DIM),
)),
Line::from(Span::styled(
&state.input.buffer,
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
format!("Cursor at position {} / {}", state.input.cursor, state.input.buffer.len()),
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Effort => {
let block = block.title(" Effort Level ");
let levels = ["low", "medium", "high", "xhigh", "max"];
let current_idx = if state.settings.temperature > 0.8 { 0usize }
else if state.settings.temperature > 0.5 { 1 }
else if state.settings.temperature > 0.3 { 2 }
else if state.settings.max_tokens > 4000 { 3 }
else { 4 };
let mut lines: Vec<Line> = levels.iter().enumerate().map(|(i, l)| {
let selected = i == current_idx;
Line::from(Span::styled(
if selected { format!("> {} (current)", l) } else { format!(" {}", l) },
if selected {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
},
))
}).collect();
lines.insert(0, Line::from(Span::styled(
"Use arrow keys to change effort level",
Style::default().fg(Theme::DIM),
)));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Mcp => {
let block = block.title(" MCP Servers ");
let lines: Vec<Line> = vec![
Line::from(Span::styled(
"MCP Server Management",
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
format!("Session dir: {}", state.session_dir.display()),
Style::default().fg(Theme::DIM),
)),
Line::from(Span::styled(
"No MCP servers configured.",
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
"Press Ctrl+P to configure provider settings.",
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Security => {
let block = block
.title(" Security ")
.border_style(Style::default().fg(if state.misc.security_armed { Theme::ERROR } else { Theme::WARNING }));
let lines = vec![
Line::from(Span::styled(
if state.misc.security_armed {
"SECURITY ESCALATION ACTIVE"
} else {
"Security Status: Normal"
},
Style::default().fg(if state.misc.security_armed { Theme::ERROR } else { Theme::INFO })
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
format!("Armed: {}", state.misc.security_armed),
Style::default().fg(if state.misc.security_armed { Theme::WARNING } else { Theme::DIM }),
)),
Line::from(Span::styled(
format!("Esc presses: {}", state.misc.esc_press_count),
Style::default().fg(Theme::DIM),
)),
Line::from(Span::styled(
"Catastrophic-op guard: active in all modes",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
"Credential exfil patterns: monitored",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
"Destructive git ops: blocked",
Style::default().fg(Theme::TEXT),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Todo => {
let block = block.title(" Tasks ");
let msg_count = state.transcript_cache.messages.len();
let lines = vec![
Line::from(Span::styled(
"Session Activity",
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
Line::from(Span::styled(
format!("Messages: {}", msg_count),
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled(
format!("Mode: {}", state.mode.name()),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Overlay: {:?}", state.misc.overlay),
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Rewind => {
let block = block.title(" Rewind ");
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
"Session History",
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"",
Style::default(),
)),
];
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::DIM),
)));
} else {
let start = if messages.len() > 5 { messages.len() - 5 } else { 0 };
for msg in &messages[start..] {
let role_str = match msg.role {
crate::dto::chat::message::Role::User => "User",
crate::dto::chat::message::Role::Assistant => "Asst",
crate::dto::chat::message::Role::System => "Sys",
crate::dto::chat::message::Role::Tool => "Tool",
};
let preview: String = msg.content.chars().take(60).collect();
lines.push(Line::from(Span::styled(
format!("[{}] {}", role_str, preview),
Style::default().fg(if matches!(msg.role, crate::dto::chat::message::Role::User) { Theme::INFO } else { Theme::TEXT }),
)));
}
if messages.len() > 5 {
lines.push(Line::from(Span::styled(
format!("... and {} more messages", messages.len() - 5),
Style::default().fg(Theme::DIM),
)));
}
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Learning => {
let block = block.title(" Learning ");
let (total, by_user, by_fb, by_proj, by_ref, act, stale, contra, human, verified, unverified) = state.session_runtime.as_ref().map(|r| {
(r.lesson_count, r.lessons_user, r.lessons_feedback, r.lessons_project, r.lessons_reference, r.lessons_active, r.lessons_stale, r.lessons_contradicted, r.lessons_human, r.lessons_verified, r.lessons_unverified)
}).unwrap_or_default();
let lines = vec![
Line::from(Span::styled(
"Lessons Dashboard",
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled(
format!("Total lessons: {}", total),
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("By Type:", Style::default().fg(Theme::DIM))),
Line::from(Span::styled(
format!(" User: {}", by_user),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Feedback: {}", by_fb),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Project: {}", by_proj),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Reference: {}", by_ref),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("Lifecycle:", Style::default().fg(Theme::DIM))),
Line::from(Span::styled(
format!(" Active: {}", act),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Stale: {}", stale),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Contradicted: {}", contra),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("Confidence:", Style::default().fg(Theme::DIM))),
Line::from(Span::styled(
format!(" Human: {}", human),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Verified: {}", verified),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Unverified: {}", unverified),
Style::default().fg(Theme::TEXT),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Usage => {
let block = block.title(" Usage ");
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = state.session_runtime.as_ref().map(|r| {
(r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start)
}).unwrap_or((0, 0, 0, 0, 0));
let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
let hours = elapsed_ms / 3600000;
let minutes = (elapsed_ms % 3600000) / 60000;
let seconds = (elapsed_ms % 60000) / 1000;
let total_tokens = tokens_in.saturating_add(tokens_out);
let lines = vec![
Line::from(Span::styled(
"Usage Dashboard",
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled(
format!("Tokens in: {}", tokens_in),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Tokens out: {}", tokens_out),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Total tokens: {}", total_tokens),
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled(
format!("API calls: {}", api_calls),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Review tokens: {}", review_tokens),
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled(
format!("Session time: {}h {}m {}s", hours, minutes, seconds),
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::Loading => {
let block = block.title(" Loading ");
let content = "Processing, please wait...";
let paragraph = Paragraph::new(content)
.block(block);
frame.render_widget(paragraph, overlay_area);
}
}
}
fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Theme::BORDER));
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...",
Style::default().fg(Theme::DIM),
));
} else {
let (before, after) = input_text.split_at(cursor_pos);
spans.push(Span::raw(before.to_string()));
spans.push(Span::styled(
if after.is_empty() { " " } else { &after[..1] },
Style::default().bg(Theme::HIGHLIGHT).fg(Theme::BG),
));
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 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),
}
}
+54
View File
@@ -0,0 +1,54 @@
use ratatui::layout::Rect;
use ratatui::style::{Style, Modifier};
use ratatui::text::{Line, Span};
use ratatui::widgets::Block;
use ratatui::Frame;
use super::theme::Theme;
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let mode_str = state.mode.name();
let mode_color = match state.mode {
crate::app::state::types::AgentMode::Auto => Theme::MODE_AUTO,
crate::app::state::types::AgentMode::Normal => Theme::MODE_NORMAL,
crate::app::state::types::AgentMode::Plan => Theme::MODE_PLAN,
crate::app::state::types::AgentMode::Yolo => Theme::MODE_YOLO,
};
let mode_indicator = if state.mode.auto_approve() {
"AUTO-APPROVE (UNRESTRICTED)".to_string()
} else {
format!("{}-APPROVE (RESTRICTED)", mode_str.to_uppercase())
};
let session_count = state.sessions.len();
let msg_count = state.transcript_cache.messages.len();
let left_text = Span::styled(
" [zesdex] ",
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
);
let center_text = Span::styled(
format!(" | STATUS: {} | PROTOCOL: ZERO-STUBS ACTIVE ", mode_indicator),
Style::default().fg(mode_color),
);
let right_text = Span::styled(
format!(" | MSG: {} | SESS: {} ", msg_count, session_count),
Style::default().fg(Theme::DIM),
);
let line = Line::from(vec![left_text, center_text, right_text]);
let block = Block::default()
.style(
Style::default()
.bg(Theme::STATUS_BAR_BG)
.fg(Theme::TEXT),
);
let paragraph = ratatui::widgets::Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
+26
View File
@@ -0,0 +1,26 @@
use ratatui::style::Color;
pub struct Theme;
impl Theme {
pub const PRIMARY: Color = Color::Cyan;
pub const SECONDARY: Color = Color::Magenta;
pub const SUCCESS: Color = Color::Green;
pub const WARNING: Color = Color::Yellow;
pub const ERROR: Color = Color::Red;
pub const INFO: Color = Color::Blue;
pub const TEXT: Color = Color::White;
pub const DIM: Color = Color::DarkGray;
pub const HIGHLIGHT: Color = Color::LightYellow;
pub const BORDER: Color = Color::Gray;
pub const ROLE_USER: Color = Color::Green;
pub const ROLE_ASSISTANT: Color = Color::Cyan;
pub const ROLE_SYSTEM: Color = Color::Blue;
pub const ROLE_TOOL: Color = Color::Yellow;
pub const BG: Color = Color::Reset;
pub const STATUS_BAR_BG: Color = Color::Blue;
pub const MODE_AUTO: Color = Color::Green;
pub const MODE_NORMAL: Color = Color::Yellow;
pub const MODE_PLAN: Color = Color::Cyan;
pub const MODE_YOLO: Color = Color::Red;
}
+86
View File
@@ -0,0 +1,86 @@
use ratatui::layout::Rect;
use ratatui::style::{Style, Modifier};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::Frame;
use super::theme::Theme;
pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(" Workflow ");
let session_runtime = match &state.session_runtime {
Some(r) => r,
None => {
let empty = Block::default().borders(Borders::ALL).title(" Workflow ");
let paragraph = ratatui::widgets::Paragraph::new(Line::from(Span::raw("No active session")))
.block(empty);
frame.render_widget(paragraph, area);
return;
}
};
let tool_count = session_runtime.tool_call_results.len();
let pending_count = session_runtime.pending_tool_queue.len();
let bash_count = session_runtime.bash_jobs.len();
let subagent_queue = session_runtime.subagent_queue;
let edit_count = session_runtime.edit_count;
let mut items = Vec::new();
items.push(ListItem::new(Line::from(Span::styled(
format!(" Messages: {}", session_runtime.messages.len()),
Style::default().fg(Theme::TEXT),
))));
items.push(ListItem::new(Line::from(Span::styled(
format!(" Tool calls completed: {}", tool_count),
Style::default().fg(Theme::SUCCESS),
))));
if pending_count > 0 {
items.push(ListItem::new(Line::from(Span::styled(
format!(" Pending tool queue: {}", pending_count),
Style::default().fg(Theme::WARNING),
))));
}
if bash_count > 0 {
items.push(ListItem::new(Line::from(Span::styled(
format!(" Active bash jobs: {}", bash_count),
Style::default().fg(Theme::WARNING),
))));
}
if subagent_queue > 0 {
items.push(ListItem::new(Line::from(Span::styled(
format!(" Subagent queue: {}", subagent_queue),
Style::default().fg(Theme::INFO),
))));
}
items.push(ListItem::new(Line::from(Span::styled(
format!(" Edits: {}", edit_count),
Style::default().fg(Theme::INFO),
))));
let phase_status = match state.mode {
crate::app::state::types::AgentMode::Auto => "Auto-running",
crate::app::state::types::AgentMode::Normal => "Awaiting input",
crate::app::state::types::AgentMode::Plan => "Planning",
crate::app::state::types::AgentMode::Yolo => "Full autonomy",
};
items.push(ListItem::new(Line::from(Span::styled(
format!(" Phase: {}", phase_status),
Style::default().fg(Theme::PRIMARY),
))));
let list = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
frame.render_widget(list, area);
}