2026-07-11 13:16:10 +07:00
|
|
|
pub mod chat;
|
|
|
|
|
pub mod status;
|
|
|
|
|
pub mod theme;
|
|
|
|
|
pub mod workflow;
|
|
|
|
|
|
|
|
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
|
|
|
use ratatui::style::{Style, Modifier};
|
|
|
|
|
use ratatui::text::{Line, Span};
|
|
|
|
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
|
|
|
|
use ratatui::Frame;
|
|
|
|
|
use theme::Theme;
|
|
|
|
|
|
|
|
|
|
pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
|
|
|
|
let area = frame.area();
|
|
|
|
|
|
|
|
|
|
let chunks = Layout::default()
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
.constraints([
|
|
|
|
|
Constraint::Min(3),
|
|
|
|
|
Constraint::Length(3),
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
])
|
|
|
|
|
.split(area);
|
|
|
|
|
|
|
|
|
|
let main_area = chunks[0];
|
|
|
|
|
let input_area = chunks[1];
|
|
|
|
|
let status_area = chunks[2];
|
|
|
|
|
|
|
|
|
|
if state.misc.overlay.is_active() {
|
|
|
|
|
let overlay = state.misc.overlay;
|
|
|
|
|
render_overlay(frame, main_area, overlay, state);
|
|
|
|
|
} else {
|
|
|
|
|
render_main_panel(frame, main_area, state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render_input_bar(frame, input_area, state);
|
|
|
|
|
status::draw_status_bar(frame, status_area, state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
|
|
|
|
match state.mode {
|
|
|
|
|
crate::app::state::types::AgentMode::Auto
|
|
|
|
|
| crate::app::state::types::AgentMode::Normal
|
|
|
|
|
| crate::app::state::types::AgentMode::Plan
|
|
|
|
|
| crate::app::state::types::AgentMode::Yolo => {
|
|
|
|
|
chat::draw_chat(frame, area, state);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::types::Overlay, state: &crate::app::state::rest::AppStateRest) {
|
|
|
|
|
let overlay_area = centered_rect(area, 70, 60);
|
|
|
|
|
|
|
|
|
|
frame.render_widget(Clear, overlay_area);
|
|
|
|
|
|
|
|
|
|
let block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(Style::default().fg(Theme::PRIMARY))
|
|
|
|
|
.style(Style::default().bg(Theme::BG));
|
|
|
|
|
|
|
|
|
|
match overlay {
|
|
|
|
|
crate::app::state::types::Overlay::None => {}
|
|
|
|
|
crate::app::state::types::Overlay::Help => {
|
|
|
|
|
let block = block.title(" Help ");
|
|
|
|
|
let content = crate::resources::HELP_TEXT;
|
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
|
|
|
.block(block)
|
|
|
|
|
.wrap(Wrap { trim: false });
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Settings => {
|
|
|
|
|
let block = block.title(" Settings ");
|
|
|
|
|
let lines = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Provider: {}", state.settings.provider),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Model: {}", state.settings.model),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Max tokens: {}", state.settings.max_tokens),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Temperature: {:.1}", state.settings.temperature),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Internet mode: {:?}", state.settings.internet_mode),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Review enabled: {}", state.settings.review_enabled),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Agents => {
|
|
|
|
|
let block = block.title(" Agents ");
|
|
|
|
|
let lines = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Sub-agent management panel",
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Subagent queue: {}", state.session_runtime.as_ref().map(|r| r.subagent_queue).unwrap_or(0)),
|
|
|
|
|
Style::default().fg(Theme::INFO),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Bash => {
|
|
|
|
|
let block = block.title(" Bash ");
|
|
|
|
|
let lines: Vec<Line> = state.session_runtime.as_ref().map(|r| {
|
|
|
|
|
r.bash_jobs.iter().map(|job| {
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("[{}] {} - {}", job.id, job.command, if job.running { "running" } else { "done" }),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
))
|
|
|
|
|
}).collect()
|
|
|
|
|
}).unwrap_or_default();
|
|
|
|
|
let paragraph = if lines.is_empty() {
|
|
|
|
|
Paragraph::new(Line::from(Span::styled(
|
|
|
|
|
"No active bash jobs",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
))).block(block)
|
|
|
|
|
} else {
|
|
|
|
|
Paragraph::new(lines).block(block)
|
|
|
|
|
};
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::QuitConfirm => {
|
|
|
|
|
let block = block
|
|
|
|
|
.title(" Quit ")
|
|
|
|
|
.border_style(Style::default().fg(Theme::ERROR));
|
|
|
|
|
let lines = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Are you sure you want to quit?",
|
|
|
|
|
Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Press Ctrl+Q again to confirm, Esc to cancel.",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Workflow => {
|
|
|
|
|
workflow::draw_workflow_panel(frame, overlay_area, state);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Onboard => {
|
|
|
|
|
let block = block.title(" Welcome to Zesdex ");
|
|
|
|
|
let content = "Press Ctrl+H for help, Ctrl+P for settings, or start typing to chat.";
|
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
|
|
|
.block(block)
|
|
|
|
|
.wrap(Wrap { trim: false });
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::OnboardProvider => {
|
|
|
|
|
let block = block.title(" Select Provider ");
|
|
|
|
|
let lines = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Configure AI Provider",
|
|
|
|
|
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Available providers:",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-11 22:10:17 +07:00
|
|
|
" Zen API - opencode.ai free model",
|
2026-07-11 13:16:10 +07:00
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
" Anthropic - Direct Claude API",
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
" OpenAI - Direct GPT API",
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Current: {} / {}", state.settings.provider, state.settings.model),
|
|
|
|
|
Style::default().fg(Theme::INFO),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Press Ctrl+P to change settings.",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::KeyInput => {
|
|
|
|
|
let block = block.title(" Input ");
|
|
|
|
|
let input_text = &state.input.buffer;
|
|
|
|
|
let display = if input_text.is_empty() {
|
|
|
|
|
"Type your input..."
|
|
|
|
|
} else {
|
|
|
|
|
input_text.as_str()
|
|
|
|
|
};
|
|
|
|
|
let paragraph = Paragraph::new(display)
|
|
|
|
|
.block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Editor => {
|
|
|
|
|
let block = block.title(" Editor ");
|
|
|
|
|
let lines = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Editor Mode",
|
|
|
|
|
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Current input buffer:",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
&state.input.buffer,
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Cursor at position {} / {}", state.input.cursor, state.input.buffer.len()),
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Effort => {
|
|
|
|
|
let block = block.title(" Effort Level ");
|
|
|
|
|
let levels = ["low", "medium", "high", "xhigh", "max"];
|
|
|
|
|
let current_idx = if state.settings.temperature > 0.8 { 0usize }
|
|
|
|
|
else if state.settings.temperature > 0.5 { 1 }
|
|
|
|
|
else if state.settings.temperature > 0.3 { 2 }
|
|
|
|
|
else if state.settings.max_tokens > 4000 { 3 }
|
|
|
|
|
else { 4 };
|
|
|
|
|
let mut lines: Vec<Line> = levels.iter().enumerate().map(|(i, l)| {
|
|
|
|
|
let selected = i == current_idx;
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
if selected { format!("> {} (current)", l) } else { format!(" {}", l) },
|
|
|
|
|
if selected {
|
|
|
|
|
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(Theme::TEXT)
|
|
|
|
|
},
|
|
|
|
|
))
|
|
|
|
|
}).collect();
|
|
|
|
|
lines.insert(0, Line::from(Span::styled(
|
|
|
|
|
"Use arrow keys to change effort level",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)));
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Mcp => {
|
|
|
|
|
let block = block.title(" MCP Servers ");
|
|
|
|
|
let lines: Vec<Line> = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"MCP Server Management",
|
|
|
|
|
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Session dir: {}", state.session_dir.display()),
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"No MCP servers configured.",
|
|
|
|
|
Style::default().fg(Theme::INFO),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Press Ctrl+P to configure provider settings.",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Security => {
|
|
|
|
|
let block = block
|
|
|
|
|
.title(" Security ")
|
|
|
|
|
.border_style(Style::default().fg(if state.misc.security_armed { Theme::ERROR } else { Theme::WARNING }));
|
|
|
|
|
let lines = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
if state.misc.security_armed {
|
|
|
|
|
"SECURITY ESCALATION ACTIVE"
|
|
|
|
|
} else {
|
|
|
|
|
"Security Status: Normal"
|
|
|
|
|
},
|
|
|
|
|
Style::default().fg(if state.misc.security_armed { Theme::ERROR } else { Theme::INFO })
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Armed: {}", state.misc.security_armed),
|
|
|
|
|
Style::default().fg(if state.misc.security_armed { Theme::WARNING } else { Theme::DIM }),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Esc presses: {}", state.misc.esc_press_count),
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Catastrophic-op guard: active in all modes",
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Credential exfil patterns: monitored",
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Destructive git ops: blocked",
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Todo => {
|
|
|
|
|
let block = block.title(" Tasks ");
|
|
|
|
|
let msg_count = state.transcript_cache.messages.len();
|
|
|
|
|
let lines = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Session Activity",
|
|
|
|
|
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Messages: {}", msg_count),
|
|
|
|
|
Style::default().fg(Theme::INFO),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Mode: {}", state.mode.name()),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Overlay: {:?}", state.misc.overlay),
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Rewind => {
|
|
|
|
|
let block = block.title(" Rewind ");
|
|
|
|
|
let mut lines: Vec<Line> = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Session History",
|
|
|
|
|
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"",
|
|
|
|
|
Style::default(),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let messages = &state.transcript_cache.messages;
|
|
|
|
|
if messages.is_empty() {
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
|
|
|
"No messages in current session.",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)));
|
|
|
|
|
} else {
|
|
|
|
|
let start = if messages.len() > 5 { messages.len() - 5 } else { 0 };
|
|
|
|
|
for msg in &messages[start..] {
|
|
|
|
|
let role_str = match msg.role {
|
|
|
|
|
crate::dto::chat::message::Role::User => "User",
|
|
|
|
|
crate::dto::chat::message::Role::Assistant => "Asst",
|
|
|
|
|
crate::dto::chat::message::Role::System => "Sys",
|
|
|
|
|
crate::dto::chat::message::Role::Tool => "Tool",
|
|
|
|
|
};
|
|
|
|
|
let preview: String = msg.content.chars().take(60).collect();
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
|
|
|
format!("[{}] {}", role_str, preview),
|
|
|
|
|
Style::default().fg(if matches!(msg.role, crate::dto::chat::message::Role::User) { Theme::INFO } else { Theme::TEXT }),
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
if messages.len() > 5 {
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
|
|
|
format!("... and {} more messages", messages.len() - 5),
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Learning => {
|
|
|
|
|
let block = block.title(" Learning ");
|
|
|
|
|
let (total, by_user, by_fb, by_proj, by_ref, act, stale, contra, human, verified, unverified) = state.session_runtime.as_ref().map(|r| {
|
|
|
|
|
(r.lesson_count, r.lessons_user, r.lessons_feedback, r.lessons_project, r.lessons_reference, r.lessons_active, r.lessons_stale, r.lessons_contradicted, r.lessons_human, r.lessons_verified, r.lessons_unverified)
|
|
|
|
|
}).unwrap_or_default();
|
|
|
|
|
let lines = vec![
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
"Lessons Dashboard",
|
|
|
|
|
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled("", Style::default())),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!("Total lessons: {}", total),
|
|
|
|
|
Style::default().fg(Theme::INFO),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled("", Style::default())),
|
|
|
|
|
Line::from(Span::styled("By Type:", Style::default().fg(Theme::DIM))),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" User: {}", by_user),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Feedback: {}", by_fb),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Project: {}", by_proj),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Reference: {}", by_ref),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled("", Style::default())),
|
|
|
|
|
Line::from(Span::styled("Lifecycle:", Style::default().fg(Theme::DIM))),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Active: {}", act),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Stale: {}", stale),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Contradicted: {}", contra),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled("", Style::default())),
|
|
|
|
|
Line::from(Span::styled("Confidence:", Style::default().fg(Theme::DIM))),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Human: {}", human),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Verified: {}", verified),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Unverified: {}", unverified),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Usage => {
|
|
|
|
|
let block = block.title(" Usage ");
|
2026-07-11 21:06:22 +07:00
|
|
|
let runtime = state.session_runtime.as_ref();
|
|
|
|
|
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime.map(|r| {
|
2026-07-11 13:16:10 +07:00
|
|
|
(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));
|
2026-07-11 21:06:22 +07:00
|
|
|
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);
|
|
|
|
|
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);
|
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(
|
|
|
|
|
"Usage Dashboard",
|
|
|
|
|
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled("", Style::default())),
|
2026-07-11 21:06:22 +07:00
|
|
|
Line::from(Span::styled("── Token Usage ──", Style::default().fg(Theme::DIM))),
|
2026-07-11 13:16:10 +07:00
|
|
|
Line::from(Span::styled(
|
2026-07-11 21:06:22 +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-11 21:06:22 +07:00
|
|
|
format!(" Self-learning tokens: {}", self_learning_total),
|
2026-07-11 13:16:10 +07:00
|
|
|
Style::default().fg(Theme::INFO),
|
|
|
|
|
)),
|
2026-07-11 21:06:22 +07:00
|
|
|
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),
|
|
|
|
|
)),
|
2026-07-11 13:16:10 +07:00
|
|
|
Line::from(Span::styled("", Style::default())),
|
2026-07-11 21:06:22 +07:00
|
|
|
Line::from(Span::styled("── Quality Trends ──", Style::default().fg(Theme::DIM))),
|
2026-07-11 13:16:10 +07:00
|
|
|
Line::from(Span::styled(
|
2026-07-11 21:06:22 +07:00
|
|
|
format!(" Edits this session: {}", edit_count),
|
2026-07-11 13:16:10 +07:00
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-11 21:06:22 +07:00
|
|
|
format!(" Reviews completed: {}", review_count),
|
|
|
|
|
Style::default().fg(Theme::TEXT),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" Lessons found: {}", lesson_count),
|
2026-07-11 13:16:10 +07:00
|
|
|
Style::default().fg(Theme::INFO),
|
|
|
|
|
)),
|
|
|
|
|
Line::from(Span::styled(
|
2026-07-11 21:06:22 +07:00
|
|
|
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),
|
2026-07-11 13:16:10 +07:00
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
)),
|
|
|
|
|
];
|
|
|
|
|
let paragraph = Paragraph::new(lines).block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
crate::app::state::types::Overlay::Loading => {
|
|
|
|
|
let block = block.title(" Loading ");
|
|
|
|
|
let content = "Processing, please wait...";
|
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
|
|
|
.block(block);
|
|
|
|
|
frame.render_widget(paragraph, overlay_area);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
|
|
|
|
let block = Block::default()
|
|
|
|
|
.borders(Borders::TOP)
|
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER));
|
|
|
|
|
|
|
|
|
|
let input_text = &state.input.buffer;
|
|
|
|
|
let cursor_pos = state.input.cursor;
|
|
|
|
|
|
|
|
|
|
let prompt = Span::styled(
|
|
|
|
|
"> ",
|
|
|
|
|
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut spans = vec![prompt];
|
|
|
|
|
|
|
|
|
|
if input_text.is_empty() {
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
"Type a message...",
|
|
|
|
|
Style::default().fg(Theme::DIM),
|
|
|
|
|
));
|
|
|
|
|
} else {
|
|
|
|
|
let (before, after) = input_text.split_at(cursor_pos);
|
|
|
|
|
spans.push(Span::raw(before.to_string()));
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
if after.is_empty() { " " } else { &after[..1] },
|
|
|
|
|
Style::default().bg(Theme::HIGHLIGHT).fg(Theme::BG),
|
|
|
|
|
));
|
|
|
|
|
if after.len() > 1 {
|
|
|
|
|
spans.push(Span::raw(after[1..].to_string()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let line = Line::from(spans);
|
|
|
|
|
let paragraph = Paragraph::new(line).block(block);
|
|
|
|
|
frame.render_widget(paragraph, area);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
|
|
|
|
let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2;
|
|
|
|
|
let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;
|
|
|
|
|
|
|
|
|
|
Rect {
|
|
|
|
|
x: area.x.saturating_add(x_pad),
|
|
|
|
|
y: area.y.saturating_add(y_pad),
|
|
|
|
|
width: area.width.saturating_sub(x_pad * 2).max(40),
|
|
|
|
|
height: area.height.saturating_sub(y_pad * 2).max(10),
|
|
|
|
|
}
|
|
|
|
|
}
|