use super::super::app::App;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
pub(super) fn render_help(f: &mut Frame, app: &App, area: Rect) {
fn kv<'a>(key: &'a str, desc: &'a str, key_color: Color) -> Line<'a> {
Line::from(vec![
Span::styled(
format!(" {:<14}", key),
Style::default().fg(key_color).add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default().fg(Color::DarkGray)),
Span::styled(desc, Style::default().fg(Color::Reset)),
])
}
fn section_header(title: &str) -> Line<'_> {
Line::from(Span::styled(
format!(" {} ", title),
Style::default()
.fg(Color::Rgb(215, 100, 20))
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))
}
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let cyan = Color::Cyan;
let version_line = format!("v{}", env!("CARGO_PKG_VERSION"));
let provider_name = app.agent_service.provider_name().to_string();
let model_name = app.default_model_name.clone();
let mut left = vec![
Line::from(""),
section_header("ABOUT"),
kv("Version", &version_line, cyan),
kv("Provider", &provider_name, cyan),
kv("Model", &model_name, cyan),
Line::from(""),
section_header("GLOBAL"),
kv("Ctrl+C", "Clear input / quit (2x)", cyan),
kv("Ctrl+N", "New session", cyan),
kv("Ctrl+L", "List sessions", cyan),
kv("Ctrl+K", "Clear session", cyan),
Line::from(""),
section_header("CHAT"),
kv("Enter", "Send message", cyan),
kv("Ctrl+J", "New line (vim)", cyan),
kv("Escape (x2)", "Clear input / abort", cyan),
kv("Page Up/Down", "Scroll history", cyan),
kv("@", "File picker", cyan),
Line::from(""),
section_header("INPUT EDITING"),
kv("↑ / ↓", "Line nav / start-end / history", cyan),
kv("← / →", "Move cursor", cyan),
kv("Ctrl/Alt+←→", "Jump word", cyan),
kv("Home / End", "Start / end of line", cyan),
kv("Ctrl+W", "Delete word (vim)", cyan),
kv("Ctrl+U", "Delete to line start (vim)", cyan),
Line::from(""),
section_header("SLASH COMMANDS"),
kv("/help", "Show this screen", cyan),
kv("/models", "Switch model", cyan),
kv("/usage", "Token & cost stats", cyan),
kv("/onboard", "Setup wizard (start)", cyan),
kv("/onboard:provider", "Jump to AI provider setup", cyan),
kv("/onboard:workspace", "Jump to workspace settings", cyan),
kv("/onboard:channels", "Jump to channel config", cyan),
kv("/onboard:voice", "Jump to voice STT/TTS setup", cyan),
kv("/onboard:image", "Jump to image handling setup", cyan),
kv("/onboard:brain", "Jump to brain/persona setup", cyan),
kv("/doctor", "Run connection health check", cyan),
kv("/new", "Start a new session", cyan),
kv("/sessions", "Session manager", cyan),
kv("/approve", "Tool approval policy", cyan),
kv("/compact", "Compact context now", cyan),
kv("/rebuild", "Build & restart from source", cyan),
kv("/evolve", "Download latest release & restart", cyan),
kv("/cd", "Change working directory", cyan),
kv("/whisper", "Speak anywhere, paste to clipboard", cyan),
];
let all_skills = crate::brain::skills::load_all_skills();
let skill_data: Vec<(String, String)> = all_skills
.iter()
.map(|s| (s.slash_name.clone(), s.description.clone()))
.collect();
let skills_count = skill_data.len();
let skills_summary = format!("Browse {} skills", skills_count);
if !skill_data.is_empty() {
left.push(Line::from(""));
left.push(section_header("SKILLS"));
left.push(kv("/skills", &skills_summary, cyan));
for (name, desc) in &skill_data {
left.push(kv(name, desc, cyan));
}
}
let brain_path = crate::brain::BrainLoader::resolve_path();
let loader = crate::brain::CommandLoader::from_brain_path(&brain_path);
let mut user_cmds = loader.load();
if !user_cmds.is_empty() {
left.push(Line::from(""));
left.push(section_header("CUSTOM COMMANDS"));
user_cmds.sort_by(|a, b| a.name.cmp(&b.name));
for cmd in &user_cmds {
left.push(kv(&cmd.name, &cmd.description, cyan));
}
}
let brain_path = crate::brain::BrainLoader::resolve_path();
let loader = crate::brain::CommandLoader::from_brain_path(&brain_path);
let mut user_cmds = loader.load();
if !user_cmds.is_empty() {
left.push(Line::from(""));
left.push(section_header("CUSTOM COMMANDS"));
user_cmds.sort_by(|a, b| a.name.cmp(&b.name));
for cmd in &user_cmds {
left.push(kv(&cmd.name, &cmd.description, cyan));
}
}
left.extend([
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled(
" [↑↓ PgUp/Dn]",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Scroll ", Style::default().fg(Color::DarkGray)),
Span::styled(
"[Esc]",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Back", Style::default().fg(Color::DarkGray)),
]),
Line::from(""),
]);
let right = vec![
Line::from(""),
section_header("SESSIONS"),
kv("↑ / ↓", "Navigate", cyan),
kv("Enter", "Load session", cyan),
kv("N", "New session", cyan),
kv("R", "Rename", cyan),
kv("D", "Delete", cyan),
kv("Esc", "Back to chat", cyan),
Line::from(""),
section_header("TOOL APPROVAL"),
kv("↑ / ↓", "Navigate options", cyan),
kv("Enter", "Confirm selection", cyan),
kv("D / Esc", "Deny", cyan),
kv("V", "Toggle details", cyan),
Line::from(""),
section_header("SPLIT PANES (from Sessions)"),
kv("| (in sessions)", "Split horizontal (L|R)", cyan),
kv("_ (in sessions)", "Split vertical (T/B)", cyan),
kv("Tab", "Cycle pane focus", cyan),
kv("Ctrl+X", "Close pane", cyan),
Line::from(""),
section_header("FEATURES"),
Line::from(vec![
Span::styled(" ✓ ", Style::default().fg(Color::Cyan)),
Span::styled(
"Markdown & Syntax Highlighting",
Style::default().fg(Color::Reset),
),
]),
Line::from(vec![
Span::styled(" ✓ ", Style::default().fg(Color::Cyan)),
Span::styled(
"Multi-line Input & Streaming",
Style::default().fg(Color::Reset),
),
]),
Line::from(vec![
Span::styled(" ✓ ", Style::default().fg(Color::Cyan)),
Span::styled(
"Session Management & History",
Style::default().fg(Color::Reset),
),
]),
Line::from(vec![
Span::styled(" ✓ ", Style::default().fg(Color::Cyan)),
Span::styled("Token & Cost Tracking", Style::default().fg(Color::Reset)),
]),
Line::from(vec![
Span::styled(" ✓ ", Style::default().fg(Color::Cyan)),
Span::styled(
"Inline Tool Approval (3 policies)",
Style::default().fg(Color::Reset),
),
]),
Line::from(""),
];
while left.len() < right.len() {
left.push(Line::from(""));
}
let left_para = Paragraph::new(left)
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
" 📚 Help & Commands ",
Style::default()
.fg(Color::Rgb(215, 100, 20))
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Color::Rgb(120, 120, 120))),
)
.scroll((app.help_scroll_offset as u16, 0));
let right_para = Paragraph::new(right)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(120, 120, 120))),
)
.scroll((app.help_scroll_offset as u16, 0));
f.render_widget(left_para, columns[0]);
f.render_widget(right_para, columns[1]);
}
pub(super) fn render_settings(f: &mut Frame, app: &App, area: Rect) {
fn section(title: &str) -> Line<'_> {
Line::from(Span::styled(
format!(" {} ", title),
Style::default()
.fg(Color::Rgb(90, 110, 150))
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))
}
fn kv<'a>(key: &'a str, val: &'a str) -> Line<'a> {
Line::from(vec![
Span::styled(
format!(" {:<20}", key),
Style::default().fg(Color::Rgb(215, 100, 20)),
),
Span::styled(val, Style::default().fg(Color::Reset)),
])
}
fn status_dot<'a>(label: &'a str, enabled: bool) -> Line<'a> {
let (dot, color) = if enabled {
("●", Color::Cyan)
} else {
("○", Color::DarkGray)
};
Line::from(vec![
Span::styled(
format!(" {:<20}", label),
Style::default().fg(Color::Rgb(215, 100, 20)),
),
Span::styled(dot, Style::default().fg(color)),
Span::styled(
if enabled { " enabled" } else { " disabled" },
Style::default().fg(Color::DarkGray),
),
])
}
let approval = if app.approval_auto_always {
"auto-always"
} else if app.approval_auto_session {
"auto-session"
} else {
"ask"
};
let memory_available = true;
let cmd_count = app.user_commands.len();
let cmd_summary = if cmd_count == 0 {
"none".to_string()
} else {
let names: Vec<&str> = app.user_commands.iter().map(|c| c.name.as_str()).collect();
format!("{} ({})", cmd_count, names.join(", "))
};
let config_path = crate::config::Config::system_config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.opencrabs/config.toml".into());
let home_dir = dirs::home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_default();
let collapse_home = |path: &str| -> String {
if !home_dir.is_empty() && path.starts_with(&home_dir) {
format!("~{}", &path[home_dir.len()..])
} else {
path.to_string()
}
};
let brain_display = collapse_home(&app.brain_path.display().to_string());
let wd_display = collapse_home(&app.working_directory.display().to_string());
let provider_name = app.provider_name();
let mut lines = vec![
Line::from(""),
section("PROVIDER"),
kv("Provider", &provider_name),
kv("Model", &app.default_model_name),
Line::from(""),
section("APPROVAL"),
kv("Policy", approval),
Line::from(""),
section("COMMANDS"),
kv("User commands", &cmd_summary),
Line::from(""),
section("MEMORY"),
status_dot("Memory search", memory_available),
Line::from(""),
section("PATHS"),
kv("Config", &config_path),
kv("Brain", &brain_display),
kv("Working dir", &wd_display),
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled(
" [↑↓ PgUp/Dn]",
Style::default()
.fg(Color::Rgb(90, 110, 150))
.add_modifier(Modifier::BOLD),
),
Span::styled(" Scroll ", Style::default().fg(Color::DarkGray)),
Span::styled(
"[Esc]",
Style::default()
.fg(Color::Rgb(215, 100, 20))
.add_modifier(Modifier::BOLD),
),
Span::styled(" Back", Style::default().fg(Color::DarkGray)),
]),
Line::from(""),
];
let min_height = area.height as usize;
while lines.len() < min_height {
lines.push(Line::from(""));
}
let para = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
" Settings ",
Style::default()
.fg(Color::Rgb(90, 110, 150))
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Color::Rgb(120, 120, 120))),
)
.scroll((app.help_scroll_offset as u16, 0));
f.render_widget(para, area);
}