2026-07-12 11:28:39 +07:00
|
|
|
|
//! Top-level TUI render pipeline: layouts the terminal into chat / input
|
2026-07-13 05:42:17 +07:00
|
|
|
|
//! / 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.
|
2026-07-12 11:28:39 +07:00
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
|
pub mod chat;
|
2026-07-12 01:25:52 +07:00
|
|
|
|
pub mod markdown;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
|
/// Top-level render entry point called once per TUI frame.
|
2026-07-11 13:16:10 +07:00
|
|
|
|
pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
|
|
|
|
|
let area = frame.area();
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── 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;
|
2026-07-12 12:43:50 +07:00
|
|
|
|
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)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── Vertical layout: chat / input / status ───────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let chunks = Layout::default()
|
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
|
.constraints([
|
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
|
Constraint::Length(3),
|
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
|
])
|
2026-07-12 12:43:50 +07:00
|
|
|
|
.split(main_area);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
2026-07-12 12:43:50 +07:00
|
|
|
|
let chat_area = chunks[0];
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let input_area = chunks[1];
|
|
|
|
|
|
let status_area = chunks[2];
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── Render main area (overlay or chat) ───────────────────────────────
|
|
|
|
|
|
if state.misc.overlay.is_active()
|
|
|
|
|
|
&& state.misc.overlay != crate::app::state::types::Overlay::Todo
|
|
|
|
|
|
{
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let overlay = state.misc.overlay;
|
2026-07-12 12:43:50 +07:00
|
|
|
|
render_overlay(frame, chat_area, overlay, state);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
} else {
|
2026-07-12 12:43:50 +07:00
|
|
|
|
render_main_panel(frame, chat_area, state);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── Input bar ────────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
render_input_bar(frame, input_area, state);
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Status bar ───────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
status::draw_status_bar(frame, status_area, state);
|
2026-07-12 10:57:32 +07:00
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── Todo side panel ──────────────────────────────────────────────────
|
2026-07-12 12:43:50 +07:00
|
|
|
|
if let Some(todo_rect) = todo_area {
|
|
|
|
|
|
render_todo_panel(frame, todo_rect, state);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── Toasts (top-right floating) ──────────────────────────────────────
|
2026-07-12 10:57:32 +07:00
|
|
|
|
render_toasts(frame, state);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Panel helpers
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
fn render_todo_panel(
|
|
|
|
|
|
frame: &mut Frame,
|
|
|
|
|
|
area: Rect,
|
|
|
|
|
|
state: &crate::app::state::rest::AppStateRest,
|
|
|
|
|
|
) {
|
2026-07-12 12:43:50 +07:00
|
|
|
|
let block = Block::default()
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.title(" 📋 Tasks ")
|
2026-07-12 12:43:50 +07:00
|
|
|
|
.borders(Borders::ALL)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.border_style(Style::default().fg(Theme::ACCENT_PURPLE))
|
2026-07-12 12:43:50 +07:00
|
|
|
|
.style(Style::default().bg(Theme::BG));
|
|
|
|
|
|
|
|
|
|
|
|
let content = if state.misc.todo_content.is_empty() {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" No tasks yet."
|
2026-07-12 12:43:50 +07:00
|
|
|
|
} else {
|
|
|
|
|
|
&state.misc.todo_content
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
|
|
|
|
.block(block)
|
|
|
|
|
|
.wrap(Wrap { trim: false });
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
2026-07-12 12:43:50 +07:00
|
|
|
|
frame.render_widget(paragraph, area);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
fn render_main_panel(
|
|
|
|
|
|
frame: &mut Frame,
|
|
|
|
|
|
area: Rect,
|
|
|
|
|
|
state: &crate::app::state::rest::AppStateRest,
|
|
|
|
|
|
) {
|
2026-07-12 03:14:52 +07:00
|
|
|
|
chat::draw_chat(frame, area, state);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Overlay rendering
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
/// 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
|
|
|
|
|
|
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)
|
2026-07-11 13:16:10 +07:00
|
|
|
|
frame.render_widget(Clear, overlay_area);
|
|
|
|
|
|
|
|
|
|
|
|
let block = Block::default()
|
|
|
|
|
|
.borders(Borders::ALL)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
2026-07-11 13:16:10 +07:00
|
|
|
|
.style(Style::default().bg(Theme::BG));
|
|
|
|
|
|
|
|
|
|
|
|
match overlay {
|
|
|
|
|
|
crate::app::state::types::Overlay::None => {}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Help ──────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Help => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let block = block
|
|
|
|
|
|
.title(Span::styled(" ❓ Help ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::INFO));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let content = crate::resources::HELP_TEXT;
|
|
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
|
|
|
|
.block(block)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.style(Style::default().bg(Theme::BG))
|
2026-07-11 13:16:10 +07:00
|
|
|
|
.wrap(Wrap { trim: false });
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Settings ──────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Settings => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let block = block
|
|
|
|
|
|
.title(Span::styled(" ⚙ Settings ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::PRIMARY));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let lines = vec![
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Provider: {}", state.settings.provider),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Model: {}", state.settings.model),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Max tokens: {}",
|
|
|
|
|
|
state.settings.max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "auto".to_string())),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Temperature: {}",
|
|
|
|
|
|
state.settings.temperature.map(|v| format!("{:.1}", v)).unwrap_or_else(|| "auto".to_string())),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Internet: {:?}", state.settings.internet_mode),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Review: {}", state.settings.review_enabled),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
];
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-12 03:22:55 +07:00
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── Bash ──────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Bash => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let lines: Vec<Line> = state.session_runtime.as_ref().map(|r| {
|
|
|
|
|
|
r.bash_jobs.iter().map(|job| {
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" [{}] {} — {}",
|
|
|
|
|
|
job.id, job.command,
|
|
|
|
|
|
if job.running { "running" } else { "done" },
|
|
|
|
|
|
),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
))
|
|
|
|
|
|
}).collect()
|
|
|
|
|
|
}).unwrap_or_default();
|
|
|
|
|
|
let paragraph = if lines.is_empty() {
|
|
|
|
|
|
Paragraph::new(Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" No active bash jobs.",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
))).block(block)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Paragraph::new(lines).block(block)
|
|
|
|
|
|
};
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Quit Confirm ──────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::QuitConfirm => {
|
|
|
|
|
|
let block = block
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.title(Span::styled(" 🚪 Quit ", Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD)))
|
2026-07-11 13:16:10 +07:00
|
|
|
|
.border_style(Style::default().fg(Theme::ERROR));
|
|
|
|
|
|
let lines = vec![
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Are you sure you want to quit?",
|
|
|
|
|
|
Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Press Enter to confirm, Esc to cancel.",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
|
|
|
|
|
];
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Workflow ──────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Workflow => {
|
|
|
|
|
|
workflow::draw_workflow_panel(frame, overlay_area, state);
|
|
|
|
|
|
}
|
2026-07-12 03:14:52 +07:00
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── Key Input ─────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::KeyInput => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let block = block
|
|
|
|
|
|
.title(Span::styled(" 🔑 API Key ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::WARNING));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let input_text = &state.input.buffer;
|
|
|
|
|
|
let display = if input_text.is_empty() {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Type your API key..."
|
2026-07-11 13:16:10 +07:00
|
|
|
|
} else {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// 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() {
|
|
|
|
|
|
let suffix = if input_text.len() > 8 { "****" } else { "" };
|
|
|
|
|
|
format!("{}{}", display, suffix)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
display.to_string()
|
2026-07-11 13:16:10 +07:00
|
|
|
|
};
|
|
|
|
|
|
let lines = vec![
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Enter API key for authentication:",
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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![
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" 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),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
|
|
|
|
|
];
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Effort ────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Effort => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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));
|
2026-07-12 01:25:52 +07:00
|
|
|
|
let levels = crate::app::mode::effort::EFFORT_LEVELS;
|
|
|
|
|
|
let current_idx = crate::app::mode::effort::current_effort(state);
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let mut lines: Vec<Line> = vec![
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" 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!(" ▸ {} (active)", l)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!(" {}", l)
|
|
|
|
|
|
},
|
2026-07-11 13:16:10 +07:00
|
|
|
|
if selected {
|
|
|
|
|
|
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().fg(Theme::TEXT)
|
|
|
|
|
|
},
|
2026-07-13 05:42:17 +07:00
|
|
|
|
)));
|
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── MCP ───────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Mcp => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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![
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" MCP Server Management",
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Session dir: {}", state.session_dir.display()),
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" No MCP servers configured.",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_MUTED),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Press Ctrl+P to configure provider settings.",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
|
|
|
|
|
];
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Todo ──────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Todo => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let msg_count = state.transcript_cache.messages.len();
|
|
|
|
|
|
let lines = vec![
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Session Activity",
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Messages: {}", msg_count),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::INFO),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Overlay: {:?}", state.misc.overlay),
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
|
|
|
|
|
];
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Rewind ────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Rewind => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let mut lines: Vec<Line> = vec![
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Use ↑↓ to navigate, Enter to rewind to that point",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
];
|
|
|
|
|
|
let messages = &state.transcript_cache.messages;
|
|
|
|
|
|
if messages.is_empty() {
|
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" No messages in current session.",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)));
|
|
|
|
|
|
} else {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let start = if messages.len() > 8 { messages.len() - 8 } else { 0 };
|
2026-07-11 13:16:10 +07:00
|
|
|
|
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",
|
|
|
|
|
|
};
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let preview: String = msg.content.chars().take(70).collect();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" [{}] {}", role_str, preview),
|
|
|
|
|
|
Style::default().fg(
|
|
|
|
|
|
if matches!(msg.role, crate::dto::chat::message::Role::User) {
|
|
|
|
|
|
Theme::INFO
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Theme::TEXT
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)));
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
if messages.len() > 8 {
|
2026-07-11 13:16:10 +07:00
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" ... and {} more messages", messages.len() - 8),
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Learning ──────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Learning => {
|
2026-07-13 02:53:30 +07:00
|
|
|
|
let h_chunks = Layout::default()
|
|
|
|
|
|
.direction(Direction::Horizontal)
|
|
|
|
|
|
.constraints([
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Constraint::Percentage(40),
|
|
|
|
|
|
Constraint::Percentage(60),
|
2026-07-13 02:53:30 +07:00
|
|
|
|
])
|
|
|
|
|
|
.split(overlay_area);
|
|
|
|
|
|
|
|
|
|
|
|
let left_block = Block::default()
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.title(Span::styled(" 📚 Lessons ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
|
2026-07-13 02:53:30 +07:00
|
|
|
|
.borders(Borders::ALL)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
2026-07-13 02:53:30 +07:00
|
|
|
|
.style(Style::default().bg(Theme::BG));
|
|
|
|
|
|
|
|
|
|
|
|
let right_block = Block::default()
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.title(Span::styled(" Details ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
|
2026-07-13 02:53:30 +07:00
|
|
|
|
.borders(Borders::ALL)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
2026-07-13 02:53:30 +07:00
|
|
|
|
.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(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" No lessons found.",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-13 02:53:30 +07:00
|
|
|
|
)));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for (i, item) in items.iter().enumerate() {
|
|
|
|
|
|
let is_selected = i == state.misc.selected_index;
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let prefix = if is_selected { " ▸ " } else { " " };
|
2026-07-13 02:53:30 +07:00
|
|
|
|
let (label, style) = match item {
|
|
|
|
|
|
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
|
|
|
|
|
|
(
|
|
|
|
|
|
format!("{}[Pending] {}", prefix, name),
|
|
|
|
|
|
if is_selected {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
2026-07-13 02:53:30 +07:00
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().fg(Theme::WARNING)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
},
|
2026-07-13 02:53:30 +07:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => {
|
|
|
|
|
|
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
|
|
|
|
|
|
(
|
|
|
|
|
|
format!("{}[{}] {}", prefix, status, name),
|
|
|
|
|
|
if is_selected {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
2026-07-13 02:53:30 +07:00
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().fg(Theme::TEXT)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
},
|
2026-07-13 02:53:30 +07:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
left_lines.push(Line::from(Span::styled(label, style)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// Scroll the left list
|
2026-07-13 02:53:30 +07:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// Right pane: details
|
2026-07-13 02:53:30 +07:00
|
|
|
|
let mut right_lines = Vec::new();
|
|
|
|
|
|
if let Some(item) = items.get(selected) {
|
|
|
|
|
|
match item {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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),
|
|
|
|
|
|
)));
|
2026-07-13 02:53:30 +07:00
|
|
|
|
for line in content.lines() {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
right_lines.push(Line::from(Span::styled(
|
|
|
|
|
|
format!(" {}", line),
|
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)));
|
2026-07-13 02:53:30 +07:00
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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),
|
|
|
|
|
|
)));
|
2026-07-13 02:53:30 +07:00
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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),
|
|
|
|
|
|
)));
|
2026-07-13 02:53:30 +07:00
|
|
|
|
for line in content.lines() {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
right_lines.push(Line::from(Span::styled(
|
|
|
|
|
|
format!(" {}", line),
|
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)));
|
2026-07-13 02:53:30 +07:00
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
right_lines.push(Line::from(Span::raw("")));
|
|
|
|
|
|
right_lines.push(Line::from(Span::styled(
|
|
|
|
|
|
" [d]/[Del] Delete Lesson",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
|
|
|
|
|
)));
|
2026-07-13 02:53:30 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
right_lines.push(Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Select a lesson on the left.",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-13 02:53:30 +07:00
|
|
|
|
)));
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let right_paragraph = Paragraph::new(right_lines)
|
|
|
|
|
|
.block(right_block)
|
|
|
|
|
|
.wrap(Wrap { trim: false });
|
2026-07-13 02:53:30 +07:00
|
|
|
|
frame.render_widget(right_paragraph, h_chunks[1]);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Usage ────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Usage => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let block = block
|
|
|
|
|
|
.title(Span::styled(" 📊 Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::INFO));
|
2026-07-11 21:06:22 +07:00
|
|
|
|
let runtime = state.session_runtime.as_ref();
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
|
2026-07-13 06:22:31 +07:00
|
|
|
|
let hours = elapsed_ms / 3_600_000;
|
|
|
|
|
|
let minutes = (elapsed_ms % 3_600_000) / 60_000;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let seconds = (elapsed_ms % 60000) / 1000;
|
|
|
|
|
|
let total_tokens = tokens_in.saturating_add(tokens_out);
|
2026-07-11 21:06:22 +07:00
|
|
|
|
let self_learning_total = review_tokens;
|
|
|
|
|
|
let main_tokens = total_tokens.saturating_sub(self_learning_total);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let lines = vec![
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Token Usage",
|
|
|
|
|
|
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Main agent: {} tokens", main_tokens),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Self-learning: {} tokens", self_learning_total),
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_MUTED),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
2026-07-11 21:06:22 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Total: {} tokens", total_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",
|
2026-07-11 21:06:22 +07:00
|
|
|
|
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Edits: {}", edit_count),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Reviews: {}", review_count),
|
2026-07-11 21:06:22 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Lessons: {}", lesson_count),
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_MUTED),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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 }),
|
2026-07-11 21:06:22 +07:00
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
2026-07-11 21:06:22 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Session: {}h {}m {}s", hours, minutes, seconds),
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
)),
|
|
|
|
|
|
];
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Loading ──────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
crate::app::state::types::Overlay::Loading => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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]);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
|
|
|
|
.block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Model Selector ───────────────────────────────────────────
|
2026-07-12 02:06:46 +07:00
|
|
|
|
crate::app::state::types::Overlay::ModelSelector => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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));
|
2026-07-12 02:06:46 +07:00
|
|
|
|
let mut lines: Vec<Line> = vec![
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
format!(" Current: {} / {}", state.settings.provider, state.settings.model),
|
2026-07-12 02:06:46 +07:00
|
|
|
|
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
|
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
|
" Providers:",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
|
|
|
|
|
)),
|
2026-07-12 02:06:46 +07:00
|
|
|
|
];
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let providers: Vec<(&String, &crate::model::app_config::ProviderConfig)> =
|
|
|
|
|
|
state.app_config.providers.iter().collect();
|
2026-07-12 02:06:46 +07:00
|
|
|
|
for (i, (name, cfg)) in providers.iter().enumerate() {
|
|
|
|
|
|
let is_current = *name == &state.settings.provider;
|
|
|
|
|
|
let is_selected = i == state.misc.selected_index;
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let prefix = if is_selected { " ▸ " } else { " " };
|
2026-07-12 02:06:46 +07:00
|
|
|
|
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)));
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
lines.push(Line::from(Span::raw("")));
|
2026-07-12 02:06:46 +07:00
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" ↑↓ navigate · Enter select · Esc close",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-12 02:06:46 +07:00
|
|
|
|
)));
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── Clear Confirm ────────────────────────────────────────────
|
2026-07-12 02:06:46 +07:00
|
|
|
|
crate::app::state::types::Overlay::ClearConfirm => {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let block = block
|
|
|
|
|
|
.title(Span::styled(" 🗑️ Clear Transcript ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::WARNING));
|
2026-07-12 02:06:46 +07:00
|
|
|
|
let lines = vec![
|
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Clear all messages from the transcript?",
|
2026-07-12 02:06:46 +07:00
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
|
)),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Line::from(Span::raw("")),
|
2026-07-12 02:06:46 +07:00
|
|
|
|
Line::from(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" Enter to confirm · Esc to cancel",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-12 02:06:46 +07:00
|
|
|
|
)),
|
|
|
|
|
|
];
|
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Input bar with autocomplete
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
|
/// Render the bottom input bar including the autocomplete dropdown above it.
|
|
|
|
|
|
///
|
2026-07-13 05:42:17 +07:00
|
|
|
|
/// 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 ────────────────────────────────────────────
|
2026-07-12 02:06:46 +07:00
|
|
|
|
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
|
|
|
|
|
|
let n = state.input.autocomplete_candidates.len().min(10) as u16;
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let dropdown_height = n + 2;
|
2026-07-12 02:06:46 +07:00
|
|
|
|
let dropdown_area = Rect {
|
|
|
|
|
|
x: area.x,
|
|
|
|
|
|
y: area.y.saturating_sub(dropdown_height),
|
2026-07-13 05:42:17 +07:00
|
|
|
|
width: area.width.min(45),
|
2026-07-12 02:06:46 +07:00
|
|
|
|
height: dropdown_height,
|
|
|
|
|
|
};
|
|
|
|
|
|
let dropdown_block = Block::default()
|
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.title(Span::styled(
|
|
|
|
|
|
" ⌘ Commands ",
|
|
|
|
|
|
Style::default().fg(Theme::PRIMARY),
|
|
|
|
|
|
))
|
|
|
|
|
|
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
2026-07-12 02:06:46 +07:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let prefix = if i == selected { " ▸ " } else { " " };
|
2026-07-12 02:06:46 +07:00
|
|
|
|
let style = if i == selected {
|
2026-07-13 05:42:17 +07:00
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::TEXT)
|
|
|
|
|
|
.bg(Theme::HIGHLIGHT_DIM)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
2026-07-12 02:06:46 +07:00
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ── Input bar ────────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
|
let block = Block::default()
|
|
|
|
|
|
.borders(Borders::TOP)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
|
|
|
|
|
.style(Style::default().bg(Theme::SURFACE));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
|
|
|
|
|
let input_text = &state.input.buffer;
|
|
|
|
|
|
let cursor_pos = state.input.cursor;
|
|
|
|
|
|
|
|
|
|
|
|
let prompt = Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
" ❯ ",
|
2026-07-11 13:16:10 +07:00
|
|
|
|
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let mut spans = vec![prompt];
|
|
|
|
|
|
|
|
|
|
|
|
if input_text.is_empty() {
|
|
|
|
|
|
spans.push(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
"Type a message or /command...",
|
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let (before, after) = input_text.split_at(cursor_pos);
|
|
|
|
|
|
spans.push(Span::raw(before.to_string()));
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let cursor_char = if after.is_empty() {
|
|
|
|
|
|
" "
|
|
|
|
|
|
} else {
|
|
|
|
|
|
&after[..1]
|
|
|
|
|
|
};
|
|
|
|
|
|
// Cursor highlight
|
2026-07-11 13:16:10 +07:00
|
|
|
|
spans.push(Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
|
cursor_char,
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.bg(Theme::HIGHLIGHT)
|
|
|
|
|
|
.fg(Theme::BG)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
2026-07-11 13:16:10 +07:00
|
|
|
|
));
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Toast notifications
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-12 10:57:32 +07:00
|
|
|
|
/// 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.
|
2026-07-13 05:42:17 +07:00
|
|
|
|
///
|
|
|
|
|
|
/// Toasts are stacked vertically with a 1-line gap. Each has a colored
|
|
|
|
|
|
/// left border and a subtle background.
|
2026-07-12 10:57:32 +07:00
|
|
|
|
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();
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let toast_w: u16 = 48;
|
2026-07-12 10:57:32 +07:00
|
|
|
|
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;
|
2026-07-13 05:42:17 +07:00
|
|
|
|
let h = line_count + 2;
|
|
|
|
|
|
let toast_area = Rect {
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
width: toast_w,
|
|
|
|
|
|
height: h,
|
|
|
|
|
|
};
|
2026-07-12 10:57:32 +07:00
|
|
|
|
if toast_area.bottom() > area.height {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
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, " 📘 "),
|
2026-07-12 10:57:32 +07:00
|
|
|
|
};
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
|
|
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())
|
2026-07-12 10:57:32 +07:00
|
|
|
|
.block(block)
|
2026-07-13 05:42:17 +07:00
|
|
|
|
.wrap(Wrap { trim: false });
|
|
|
|
|
|
|
2026-07-12 10:57:32 +07:00
|
|
|
|
frame.render_widget(paragraph, toast_area);
|
|
|
|
|
|
y = y.saturating_add(h).saturating_add(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|