2026-07-20 09:04:57 +07:00
|
|
|
|
//! Top-level TUI render pipeline: layouts the terminal into chat / input
|
|
|
|
|
|
//! / status regions, dispatches overlay rendering with glassmorphism-style
|
|
|
|
|
|
//! centered panels, and floats toast notifications over the top-right corner.
|
|
|
|
|
|
|
|
|
|
|
|
pub mod chat;
|
|
|
|
|
|
pub mod markdown;
|
|
|
|
|
|
pub mod sidebar;
|
|
|
|
|
|
pub mod status;
|
|
|
|
|
|
pub mod theme;
|
|
|
|
|
|
pub mod workflow;
|
|
|
|
|
|
pub mod overlays;
|
|
|
|
|
|
|
|
|
|
|
|
use crate::state::AppStateRest;
|
|
|
|
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
|
|
|
|
use ratatui::style::{Modifier, Style};
|
|
|
|
|
|
use ratatui::text::{Line, Span};
|
|
|
|
|
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
|
|
|
|
|
use ratatui::Frame;
|
|
|
|
|
|
use theme::Theme;
|
|
|
|
|
|
use zesdex_infrastructure::ToastKind;
|
|
|
|
|
|
|
|
|
|
|
|
const SIDEBAR_MIN_WIDTH: u16 = 90;
|
|
|
|
|
|
|
2026-07-20 13:52:20 +07:00
|
|
|
|
/// Pre-render hook: update mutable caches (display lines, token count)
|
|
|
|
|
|
/// before the immutable `draw` pass. Called once per frame when dirty.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This separates cache mutation from rendering so `draw` can take
|
|
|
|
|
|
/// `&AppStateRest` (required by `terminal.draw` closure constraints).
|
|
|
|
|
|
pub fn pre_render(state: &mut AppStateRest) {
|
|
|
|
|
|
chat::pre_render_chat(state);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 09:04:57 +07:00
|
|
|
|
/// Top-level render entry point called once per TUI frame.
|
|
|
|
|
|
pub fn draw(frame: &mut Frame, state: &AppStateRest) {
|
|
|
|
|
|
let area = frame.area();
|
|
|
|
|
|
|
|
|
|
|
|
let show_sidebar = area.width > SIDEBAR_MIN_WIDTH;
|
|
|
|
|
|
let (main_area, sidebar_area) = if show_sidebar {
|
|
|
|
|
|
let has_workflow = !state.workflow_engine.agents.is_empty();
|
|
|
|
|
|
let sidebar_width = if has_workflow { 48 } else { 30 };
|
|
|
|
|
|
let h_chunks = Layout::default()
|
|
|
|
|
|
.direction(Direction::Horizontal)
|
|
|
|
|
|
.constraints([Constraint::Min(40), Constraint::Length(sidebar_width)])
|
|
|
|
|
|
.split(area);
|
|
|
|
|
|
(h_chunks[0], Some(h_chunks[1]))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(area, None)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let chunks = Layout::default()
|
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
|
.constraints([
|
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
|
Constraint::Length(3),
|
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
|
])
|
|
|
|
|
|
.split(main_area);
|
|
|
|
|
|
|
|
|
|
|
|
let chat_area = chunks[0];
|
|
|
|
|
|
let input_area = chunks[1];
|
|
|
|
|
|
let status_area = chunks[2];
|
|
|
|
|
|
|
|
|
|
|
|
if state.misc.overlay.is_active() {
|
|
|
|
|
|
let overlay = state.misc.overlay;
|
|
|
|
|
|
overlays::render_overlay(frame, chat_area, overlay, state);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
render_main_panel(frame, chat_area, state);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
render_input_bar(frame, input_area, state);
|
|
|
|
|
|
status::draw_status_bar(frame, status_area, state);
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(sidebar_rect) = sidebar_area {
|
|
|
|
|
|
sidebar::draw_sidebar(frame, sidebar_rect, state);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
render_toasts(frame, state);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render_main_panel(frame: &mut Frame, area: Rect, state: &AppStateRest) {
|
|
|
|
|
|
chat::draw_chat(frame, area, state);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render_input_bar(frame: &mut Frame, area: Rect, state: &AppStateRest) {
|
|
|
|
|
|
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
|
|
|
|
|
|
let n = state.input.autocomplete_candidates.len().min(10) as u16;
|
|
|
|
|
|
let dropdown_height = n + 2;
|
|
|
|
|
|
let dropdown_area = Rect {
|
|
|
|
|
|
x: area.x,
|
|
|
|
|
|
y: area.y.saturating_sub(dropdown_height),
|
|
|
|
|
|
width: area.width.min(45),
|
|
|
|
|
|
height: dropdown_height,
|
|
|
|
|
|
};
|
|
|
|
|
|
let dropdown_title = match state.input.autocomplete_kind {
|
|
|
|
|
|
crate::state::AutocompleteKind::Command => " ⌘ Commands ",
|
|
|
|
|
|
crate::state::AutocompleteKind::FileMention => " 📁 Files ",
|
|
|
|
|
|
};
|
|
|
|
|
|
let dropdown_block = Block::default()
|
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
|
|
|
|
|
.title(Span::styled(
|
|
|
|
|
|
dropdown_title,
|
|
|
|
|
|
Style::default().fg(Theme::PRIMARY),
|
|
|
|
|
|
))
|
|
|
|
|
|
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
|
|
|
|
|
|
|
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
|
|
|
|
let selected = state.input.autocomplete_idx;
|
|
|
|
|
|
for (i, candidate) in state
|
|
|
|
|
|
.input
|
|
|
|
|
|
.autocomplete_candidates
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.take(10)
|
|
|
|
|
|
{
|
|
|
|
|
|
let prefix = if i == selected { " ▸ " } else { " " };
|
|
|
|
|
|
let style = if i == selected {
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::TEXT)
|
|
|
|
|
|
.bg(Theme::HIGHLIGHT_DIM)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().fg(Theme::TEXT)
|
|
|
|
|
|
};
|
|
|
|
|
|
let label = format!("{prefix}{candidate}");
|
|
|
|
|
|
lines.push(Line::from(Span::styled(label, style)));
|
|
|
|
|
|
}
|
|
|
|
|
|
let dropdown = Paragraph::new(lines).block(dropdown_block);
|
|
|
|
|
|
frame.render_widget(dropdown, dropdown_area);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let block = Block::default()
|
|
|
|
|
|
.borders(Borders::TOP)
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
|
|
|
|
|
.style(Style::default().bg(Theme::SURFACE));
|
|
|
|
|
|
|
|
|
|
|
|
let input_text = &state.input.buffer;
|
|
|
|
|
|
let cursor_pos = state.input.cursor;
|
|
|
|
|
|
|
|
|
|
|
|
let prompt = Span::styled(
|
|
|
|
|
|
" ❯ ",
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::PRIMARY)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let mut spans = vec![prompt];
|
|
|
|
|
|
|
|
|
|
|
|
if input_text.is_empty() {
|
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
|
"Type a message or /command...",
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::TEXT_DIM)
|
|
|
|
|
|
.add_modifier(Modifier::ITALIC),
|
|
|
|
|
|
));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let (before, after) = input_text.split_at(cursor_pos);
|
|
|
|
|
|
spans.push(Span::raw(before.to_string()));
|
2026-07-20 12:42:53 +07:00
|
|
|
|
let (cursor_char, after_char) = if after.is_empty() {
|
|
|
|
|
|
(" ".to_string(), "")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let c = after.chars().next().unwrap();
|
|
|
|
|
|
let char_len = c.len_utf8();
|
|
|
|
|
|
(c.to_string(), &after[char_len..])
|
|
|
|
|
|
};
|
2026-07-20 09:04:57 +07:00
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
|
cursor_char,
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.bg(Theme::HIGHLIGHT)
|
|
|
|
|
|
.fg(Theme::BG)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
|
));
|
2026-07-20 12:42:53 +07:00
|
|
|
|
if !after_char.is_empty() {
|
|
|
|
|
|
spans.push(Span::raw(after_char.to_string()));
|
2026-07-20 09:04:57 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let line = Line::from(spans);
|
|
|
|
|
|
let paragraph = Paragraph::new(line).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, area);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
|
|
|
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
|
let active: Vec<&zesdex_infrastructure::Toast> = state
|
|
|
|
|
|
.misc
|
|
|
|
|
|
.toasts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|t| !t.expired(now_ms))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
if active.is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let area = frame.area();
|
|
|
|
|
|
let toast_w: u16 = 48;
|
|
|
|
|
|
let x = area.width.saturating_sub(toast_w).saturating_sub(2);
|
|
|
|
|
|
let mut y: u16 = 1;
|
|
|
|
|
|
|
|
|
|
|
|
for toast in active.iter().rev().take(4) {
|
|
|
|
|
|
let line_count = toast.message.lines().count().max(1) as u16;
|
|
|
|
|
|
let h = line_count + 2;
|
|
|
|
|
|
let toast_area = Rect {
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
width: toast_w,
|
|
|
|
|
|
height: h,
|
|
|
|
|
|
};
|
|
|
|
|
|
if toast_area.bottom() > area.height {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
frame.render_widget(Clear, toast_area);
|
|
|
|
|
|
|
|
|
|
|
|
let (border_color, icon) = match toast.kind {
|
|
|
|
|
|
ToastKind::Success => (Theme::SUCCESS, " ✓ "),
|
|
|
|
|
|
ToastKind::Warning => (Theme::WARNING, " ⚠ "),
|
|
|
|
|
|
ToastKind::Error => (Theme::ERROR, " ✗ "),
|
|
|
|
|
|
ToastKind::Info => (Theme::INFO, " ℹ "),
|
|
|
|
|
|
ToastKind::Lesson => (Theme::ACCENT_PURPLE, " 📘 "),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let block = Block::default()
|
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
|
.border_style(Style::default().fg(border_color))
|
|
|
|
|
|
.title(Span::styled(icon, Style::default().fg(border_color)))
|
|
|
|
|
|
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
|
|
|
|
|
|
|
|
|
|
|
let paragraph = Paragraph::new(toast.message.as_str())
|
|
|
|
|
|
.block(block)
|
|
|
|
|
|
.wrap(Wrap { trim: false });
|
|
|
|
|
|
|
|
|
|
|
|
frame.render_widget(paragraph, toast_area);
|
|
|
|
|
|
y = y.saturating_add(h).saturating_add(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Split `items` into the slice that fits within `max_visible` entries and
|
|
|
|
|
|
/// the count of items hidden beyond that limit.
|
|
|
|
|
|
pub(crate) fn split_for_display<T>(items: &[T], max_visible: usize) -> (&[T], usize) {
|
|
|
|
|
|
if items.len() <= max_visible {
|
|
|
|
|
|
(items, 0)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(&items[..max_visible], items.len() - max_visible)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Build the dim trailing hint line a sidebar widget shows when its
|
|
|
|
|
|
/// content is truncated.
|
|
|
|
|
|
pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> {
|
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
|
format!(" +{hidden} more — {command}"),
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::TEXT_DIM)
|
|
|
|
|
|
.add_modifier(Modifier::ITALIC),
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|