Files
zesdex/src/view/mod.rs
T

665 lines
29 KiB
Rust
Raw Normal View History

//! Top-level TUI render pipeline: layouts the terminal into chat / input
//! / status regions, dispatches overlay rendering, and floats toast
//! notifications over the top-right corner.
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;
/// Top-level render entry point called once per TUI frame.
///
/// Flow: split the frame into main / input / status regions → if an
/// overlay is active, render it inside the main region; otherwise render
/// the chat transcript → always render the input bar and status bar →
/// overlay toast notifications in the top-right corner.
///
/// Why: a single function owns the layout so every state change
/// re-renders the whole UI from a known template.
///
/// Return: nothing; writes directly into `frame`.
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);
// Toast notifications at top-right (like Hyprland)
render_toasts(frame, state);
}
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
chat::draw_chat(frame, area, state);
}
/// Render the active modal overlay (Help, Settings, Workflow, Bash, Editor, etc.).
///
/// Flow: compute a centered sub-area → clear it to make the rest of the
/// frame visible behind → match on the Overlay variant to pick the
/// panel's title, content Lines, and styling → render as a Paragraph or
/// delegate to a specialized drawer (e.g. `workflow::draw_workflow_panel`).
///
/// Why: each Overlay variant has its own data sources (settings,
/// session_runtime, app_config) and its own visual treatment, so they
/// are dispatched individually rather than table-driven.
///
/// Return: nothing; draws directly into `frame`.
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::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 Enter 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::Workflow => {
workflow::draw_workflow_panel(frame, overlay_area, state);
}
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 = crate::app::mode::effort::EFFORT_LEVELS;
let current_idx = crate::app::mode::effort::current_effort(state);
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::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!("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 runtime = state.session_runtime.as_ref();
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime.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 (edit_count, lesson_count, review_count, consec_empty) = runtime.map(|r| {
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
}).unwrap_or((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 self_learning_total = review_tokens;
let main_tokens = total_tokens.saturating_sub(self_learning_total);
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("── Token Usage ──", Style::default().fg(Theme::DIM))),
Line::from(Span::styled(
format!(" Main agent tokens: {}", main_tokens),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Self-learning tokens: {}", self_learning_total),
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled(
format!(" Total tokens: {}", total_tokens),
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" API calls: {}", api_calls),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("── Quality Trends ──", Style::default().fg(Theme::DIM))),
Line::from(Span::styled(
format!(" Edits this session: {}", edit_count),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Reviews completed: {}", review_count),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Lessons found: {}", lesson_count),
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled(
format!(" Consecutive empty revs: {}", consec_empty),
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::DIM }),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("── Session ──", Style::default().fg(Theme::DIM))),
Line::from(Span::styled(
format!(" Duration: {}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);
}
crate::app::state::types::Overlay::ModelSelector => {
let block = block.title(" Model Selector ");
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::styled("", Style::default())),
Line::from(Span::styled("Providers:", Style::default().fg(Theme::DIM))),
];
let providers: Vec<(&String, &crate::model::app_config::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::styled("", Style::default())));
lines.push(Line::from(Span::styled(
"↑↓ navigate · Enter select · Esc close · /model add <name> <url> to add",
Style::default().fg(Theme::DIM),
)));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::ClearConfirm => {
let block = block.title(" Clear Transcript ");
let lines = vec![
Line::from(Span::styled(
"Clear all messages from the transcript?",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled(
"Enter to confirm · Esc to cancel",
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
}
}
/// Render the bottom input bar including the autocomplete dropdown above it.
///
/// Flow: if autocomplete is open and has candidates, draw a borderless
/// dropdown anchored just above the input bar showing up to 10 candidates
/// with the current selection highlighted → then render the prompt,
/// placeholder, and the buffer with a single-character highlight under
/// the cursor position.
///
/// Why: the cursor highlight is drawn by splitting the buffer at
/// `state.input.cursor` and styling one character (or trailing space)
/// with the highlight color, since ratatui Paragraph does not expose a
/// native cursor widget.
///
/// Return: nothing; draws directly into `frame` at `area`.
fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
// Render autocomplete dropdown if visible
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; // border + items
let dropdown_area = Rect {
x: area.x,
y: area.y.saturating_sub(dropdown_height),
width: area.width.min(40),
height: dropdown_height,
};
let dropdown_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(" Commands ");
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::BG).bg(Theme::HIGHLIGHT)
} 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));
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);
}
/// Render active toasts as a floating stack at top-right of the terminal.
/// Each toast auto-expires after its lifetime_ms. Max 4 visible at once.
fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
let now_ms = chrono::Utc::now().timestamp_millis();
let active: Vec<&crate::app::state::types::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 = 45;
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; // border top + border bottom
let toast_area = Rect { x, y, width: toast_w, height: h };
if toast_area.bottom() > area.height {
break;
}
frame.render_widget(ratatui::widgets::Clear, toast_area);
let border_color = match toast.kind {
crate::app::state::types::ToastKind::Success => Theme::SUCCESS,
crate::app::state::types::ToastKind::Warning => Theme::WARNING,
crate::app::state::types::ToastKind::Error => Theme::ERROR,
crate::app::state::types::ToastKind::Info => Theme::INFO,
crate::app::state::types::ToastKind::Lesson => Theme::PRIMARY,
};
let block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_style(ratatui::style::Style::default().fg(border_color))
.style(ratatui::style::Style::default().bg(ratatui::style::Color::Black));
let paragraph = ratatui::widgets::Paragraph::new(toast.message.as_str())
.block(block)
.wrap(ratatui::widgets::Wrap { trim: false });
frame.render_widget(paragraph, toast_area);
y = y.saturating_add(h).saturating_add(1);
}
}
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),
}
}