Files
zesdex/src/view/mod.rs
T
asepharyana 29a9fae3f6 ci: add GitHub Actions workflows with semantic-release auto-versioning
chore: fix all 702 clippy warnings across codebase
- auto-fix 475 via cargo clippy --fix
- fix remaining 227 manually: uninlined_format_args, redundant_closure, match_same_arms,
  underscore_binding, format_push_string, items_after_statements, needless_pass_by_value,
  clone_on_copy, case_sensitive_extension, single_match/let-else, write_with_newline,
  and other clippy lints
2026-07-13 08:12:12 +07:00

976 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! 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.
//!
//! Design: dark background with vibrant accent-colored overlays. Each overlay
//! variant gets a surface-colored centered panel with proper padding,
//! a title bar with accent border, and consistent typographic hierarchy.
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.
pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
let area = frame.area();
// ── Determine if we need a side panel (todo) ─────────────────────────
let show_todo = !state.misc.todo_content.is_empty()
|| state.misc.overlay == crate::app::state::types::Overlay::Todo;
let (main_area, todo_area) = if show_todo && area.width > 60 {
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(40),
Constraint::Length(30),
])
.split(area);
(h_chunks[0], Some(h_chunks[1]))
} else {
(area, None)
};
// ── Vertical layout: chat / input / status ───────────────────────────
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];
// ── Render main area (overlay or chat) ───────────────────────────────
if state.misc.overlay.is_active()
&& state.misc.overlay != crate::app::state::types::Overlay::Todo
{
let overlay = state.misc.overlay;
render_overlay(frame, chat_area, overlay, state);
} else {
render_main_panel(frame, chat_area, state);
}
// ── Input bar ────────────────────────────────────────────────────────
render_input_bar(frame, input_area, state);
// ── Status bar ───────────────────────────────────────────────────────
status::draw_status_bar(frame, status_area, state);
// ── Todo side panel ──────────────────────────────────────────────────
if let Some(todo_rect) = todo_area {
render_todo_panel(frame, todo_rect, state);
}
// ── Toasts (top-right floating) ──────────────────────────────────────
render_toasts(frame, state);
}
// ────────────────────────────────────────────────────────────────────────────
// Panel helpers
// ────────────────────────────────────────────────────────────────────────────
fn render_todo_panel(
frame: &mut Frame,
area: Rect,
state: &crate::app::state::rest::AppStateRest,
) {
let block = Block::default()
.title(" 📋 Tasks ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::ACCENT_PURPLE))
.style(Style::default().bg(Theme::BG));
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);
}
fn render_main_panel(
frame: &mut Frame,
area: Rect,
state: &crate::app::state::rest::AppStateRest,
) {
chat::draw_chat(frame, area, state);
}
// ────────────────────────────────────────────────────────────────────────────
// Overlay rendering
// ────────────────────────────────────────────────────────────────────────────
/// Render the active modal overlay as a centered panel.
///
/// Each overlay gets a surface-colored panel with:
/// - A top accent border strip (colored per variant)
/// - A title line with icon
/// - Content area with proper spacing
#[allow(clippy::too_many_lines)]
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, 75, 70);
// Clear the area behind the overlay (semi-transparent effect)
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 {
crate::app::state::types::Overlay::None => {}
// ── Help ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Help => {
let block = block
.title(Span::styled(" ❓ Help ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::INFO));
let content = crate::resources::HELP_TEXT;
let paragraph = Paragraph::new(content)
.block(block)
.style(Style::default().bg(Theme::BG))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, overlay_area);
}
// ── Settings ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Settings => {
let block = block
.title(Span::styled(" ⚙ Settings ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(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.review_enabled),
Style::default().fg(Theme::TEXT),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
// ── Bash ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Bash => {
let block = block
.title(Span::styled(" 💻 Bash Jobs ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(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, overlay_area);
}
// ── Quit Confirm ──────────────────────────────────────────────
crate::app::state::types::Overlay::QuitConfirm => {
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, overlay_area);
}
// ── Workflow ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Workflow => {
workflow::draw_workflow_panel(frame, overlay_area, state);
}
// ── Key Input ─────────────────────────────────────────────────
crate::app::state::types::Overlay::KeyInput => {
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 {
// Mask the key for display
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, overlay_area);
}
// ── Editor ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Editor => {
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, overlay_area);
}
// ── Effort ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Effort => {
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 = crate::app::mode::effort::EFFORT_LEVELS;
let current_idx = crate::app::mode::effort::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, overlay_area);
}
// ── MCP ───────────────────────────────────────────────────────
crate::app::state::types::Overlay::Mcp => {
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, overlay_area);
}
// ── Todo ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Todo => {
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 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::raw("")),
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::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
// ── Rewind ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Rewind => {
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 {
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(70).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() > 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, overlay_area);
}
// ── Learning ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Learning => {
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(overlay_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 = crate::app::mode::learning::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 {
crate::app::mode::learning::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)
},
)
}
crate::app::mode::learning::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)));
}
}
// Scroll the left list
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]);
// Right pane: details
let mut right_lines = Vec::new();
if let Some(item) = items.get(selected) {
match item {
crate::app::mode::learning::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),
)));
}
crate::app::mode::learning::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]);
}
// ── Usage ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Usage => {
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 (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime
.map_or((0, 0, 0, 0, 0), |r| {
(
r.usage.tokens_in,
r.usage.tokens_out,
r.usage.api_calls,
r.usage.review_tokens,
r.session_start,
)
});
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 elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
let hours = elapsed_ms / 3_600_000;
let minutes = (elapsed_ms % 3_600_000) / 60_000;
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(
" Token Usage",
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(" Main agent: {main_tokens} tokens"),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Self-learning: {self_learning_total} tokens"),
Style::default().fg(Theme::TEXT_MUTED),
)),
Line::from(Span::styled(
format!(" Total: {total_tokens} tokens"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" API calls: {api_calls}"),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Activity",
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" Edits: {edit_count}"),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Reviews: {review_count}"),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Lessons: {lesson_count}"),
Style::default().fg(Theme::TEXT_MUTED),
)),
Line::from(Span::styled(
format!(" Empty reviews: {}",
if consec_empty > 3 {
format!("{consec_empty} ⚠")
} else {
consec_empty.to_string()
},
),
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(" Session: {hours}h {minutes}m {seconds}s"),
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
// ── Loading ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Loading => {
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, overlay_area);
}
// ── Model Selector ───────────────────────────────────────────
crate::app::state::types::Overlay::ModelSelector => {
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, &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::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, overlay_area);
}
// ── Clear Confirm ────────────────────────────────────────────
crate::app::state::types::Overlay::ClearConfirm => {
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, overlay_area);
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// Input bar with autocomplete
// ────────────────────────────────────────────────────────────────────────────
/// Render the bottom input bar including the autocomplete dropdown above it.
///
/// The bar has a subtle top border, a `` prompt, the user's buffer with
/// a highlighted cursor position, and placeholder text when empty.
fn render_input_bar(
frame: &mut Frame,
area: Rect,
state: &crate::app::state::rest::AppStateRest,
) {
// ── Autocomplete dropdown ────────────────────────────────────────────
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_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
" ⌘ Commands ",
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);
}
// ── Input bar ────────────────────────────────────────────────────────
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]
};
// Cursor highlight
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);
}
// ────────────────────────────────────────────────────────────────────────────
// Toast notifications
// ────────────────────────────────────────────────────────────────────────────
/// 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.
///
/// Toasts are stacked vertically with a 1-line gap. Each has a colored
/// left border and a subtle background.
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 = 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 {
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::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);
}
}
// ────────────────────────────────────────────────────────────────────────────
// Layout helpers
// ────────────────────────────────────────────────────────────────────────────
/// Compute a centered rectangle within `area` at the given percentage width
/// and height. The result is always at least 40 cols wide and 10 rows tall.
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),
}
}