feat(tui): OpenCode styling, start screen, suggestions popup, enlarged prompt card, and themes

This commit is contained in:
katR 2026-09-26 17:48:45 +05:30
parent eb4399177e
commit 7d6fd2d84f
3 changed files with 732 additions and 203 deletions

View file

@ -40,8 +40,80 @@ pub enum TuiMode {
PersonaSelect,
PipelineSelect,
SessionSelect,
ThemeSelect,
}
#[derive(Debug, Clone)]
pub struct CommandSuggestion {
pub name: &'static str,
pub description: &'static str,
}
pub const ALL_COMMANDS: &[CommandSuggestion] = &[
CommandSuggestion {
name: "/settings",
description: "Open settings menu",
},
CommandSuggestion {
name: "/session",
description: "Resume or manage saved sessions",
},
CommandSuggestion {
name: "/theme",
description: "Select UI color theme",
},
CommandSuggestion {
name: "/new",
description: "Start a fresh session",
},
CommandSuggestion {
name: "/usage",
description: "View rate limit and budget usage counters",
},
CommandSuggestion {
name: "/config",
description: "View active system configuration",
},
CommandSuggestion {
name: "/stealth",
description: "Toggle stealth candidate models",
},
CommandSuggestion {
name: "/escalate",
description: "Toggle complexity escalation",
},
CommandSuggestion {
name: "/personas",
description: "Open persona selector modal",
},
CommandSuggestion {
name: "/pipelines",
description: "Open pipeline selector modal",
},
CommandSuggestion {
name: "/clear",
description: "Clear current conversation screen",
},
CommandSuggestion {
name: "/help",
description: "Show command and shortcut help",
},
];
pub const THEMES: &[(&str, &str)] = &[
(
"opencode",
"Default OpenCode Dark - Deep slate, cyan accent, forest tools",
),
("warm", "Warm Amber - Dark charcoal, golden amber accents"),
("forest", "Forest Mint - Deep pine, emerald & mint accents"),
(
"cyberpunk",
"Cyberpunk - Midnight, neon pink & electric blue",
),
("mono", "Monochrome - High contrast grayscale minimal"),
];
#[derive(Debug, Clone)]
pub struct PersonaItem {
pub name: String,
@ -135,6 +207,8 @@ pub struct AppState {
// UX & Theme
pub expand_tool_outputs: bool,
pub current_theme: String,
pub selected_suggestion_idx: usize,
pub selected_theme_idx: usize,
}
impl AppState {
@ -162,11 +236,7 @@ impl AppState {
let session_summaries = Session::list(&config_dir).unwrap_or_default();
Self {
messages: vec![ChatMessage {
sender: MessageSender::System,
content: "brokecrew initialized. [Tab] persona | [Ctrl+P] pipeline | [Ctrl+S] sessions | [Ctrl+L] clear".to_string(),
fell_back: false,
}],
messages: Vec::new(),
input: String::new(),
cursor_pos: 0,
mode: TuiMode::Normal,
@ -194,16 +264,34 @@ impl AppState {
allow_stealth: false,
escalate: false,
expand_tool_outputs: false,
current_theme: "default".to_string(),
current_theme: "opencode".to_string(),
selected_suggestion_idx: 0,
selected_theme_idx: 0,
}
}
pub fn get_matching_suggestions(&self) -> Vec<&'static CommandSuggestion> {
if !self.input.starts_with('/') {
return Vec::new();
}
let query = self.input.to_lowercase();
ALL_COMMANDS
.iter()
.filter(|c| c.name.starts_with(&query))
.collect()
}
pub fn is_start_screen(&self) -> bool {
self.messages.is_empty()
}
pub fn cycle_theme(&mut self) {
self.current_theme = match self.current_theme.as_str() {
"default" => "warm".to_string(),
"opencode" => "warm".to_string(),
"warm" => "forest".to_string(),
"forest" => "mono".to_string(),
_ => "default".to_string(),
"forest" => "cyberpunk".to_string(),
"cyberpunk" => "mono".to_string(),
_ => "opencode".to_string(),
};
}
@ -485,3 +573,44 @@ impl AppState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_app_start_screen_and_suggestions() {
let mut app = AppState::new(vec![], vec![], 20, 50, PathBuf::from("/tmp/brokecrew_test"));
assert!(app.is_start_screen());
app.input = "/se".to_string();
let sugs = app.get_matching_suggestions();
assert!(!sugs.is_empty());
assert!(sugs.iter().any(|s| s.name == "/session"));
assert!(sugs.iter().any(|s| s.name == "/settings"));
app.input = "hello world".to_string();
let sugs2 = app.get_matching_suggestions();
assert!(sugs2.is_empty());
app.add_user_message("hello".to_string());
assert!(!app.is_start_screen());
}
#[test]
fn test_app_theme_cycling() {
let mut app = AppState::new(vec![], vec![], 20, 50, PathBuf::from("/tmp/brokecrew_test"));
assert_eq!(app.current_theme, "opencode");
app.cycle_theme();
assert_eq!(app.current_theme, "warm");
app.cycle_theme();
assert_eq!(app.current_theme, "forest");
app.cycle_theme();
assert_eq!(app.current_theme, "cyberpunk");
app.cycle_theme();
assert_eq!(app.current_theme, "mono");
app.cycle_theme();
assert_eq!(app.current_theme, "opencode");
}
}

View file

@ -455,6 +455,28 @@ fn handle_key_event(
_ => {}
},
TuiMode::ThemeSelect => match key.code {
KeyCode::Esc => {
app.mode = TuiMode::Normal;
}
KeyCode::Up => {
app.selected_theme_idx = app.selected_theme_idx.saturating_sub(1);
}
KeyCode::Down => {
if app.selected_theme_idx + 1 < app::THEMES.len() {
app.selected_theme_idx += 1;
}
}
KeyCode::Enter => {
if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) {
app.current_theme = name.to_string();
app.log_working_event(&format!("[theme] selected {}", name));
}
app.mode = TuiMode::Normal;
}
_ => {}
},
TuiMode::Normal => {
// Mode switching and shortcuts
if key.modifiers.contains(KeyModifiers::CONTROL) {
@ -509,6 +531,46 @@ fn handle_key_event(
}
}
// Command suggestions navigation when typing slash command
if app.input.starts_with('/') {
let suggestions = app.get_matching_suggestions();
if !suggestions.is_empty() {
match key.code {
KeyCode::Up => {
app.selected_suggestion_idx =
app.selected_suggestion_idx.saturating_sub(1);
return false;
}
KeyCode::Down => {
if app.selected_suggestion_idx + 1 < suggestions.len() {
app.selected_suggestion_idx += 1;
}
return false;
}
KeyCode::Tab => {
if let Some(sug) = suggestions.get(app.selected_suggestion_idx) {
app.input = format!("{} ", sug.name);
app.cursor_pos = app.input.len();
app.selected_suggestion_idx = 0;
}
return false;
}
KeyCode::Enter if !app.input.contains(' ') => {
// If input is purely a command without arguments, select the highlighted suggestion
if let Some(sug) = suggestions.get(app.selected_suggestion_idx) {
let cmd = sug.name.to_string();
app.input.clear();
app.cursor_pos = 0;
app.selected_suggestion_idx = 0;
handle_slash_command(app, config, &cmd);
return false;
}
}
_ => {}
}
}
}
match key.code {
KeyCode::Esc => unreachable!(),
KeyCode::Tab => {
@ -534,15 +596,18 @@ fn handle_key_event(
}
KeyCode::Backspace => {
app.delete_char_before_cursor();
app.selected_suggestion_idx = 0;
}
KeyCode::Delete => {
app.delete_char_under_cursor();
app.selected_suggestion_idx = 0;
}
KeyCode::Enter => {
let prompt = app.input.trim().to_string();
if !prompt.is_empty() && !app.is_generating {
app.input.clear();
app.cursor_pos = 0;
app.selected_suggestion_idx = 0;
if prompt.starts_with('/') {
handle_slash_command(app, config, &prompt);
@ -571,6 +636,7 @@ fn handle_key_event(
}
KeyCode::Char(c) => {
app.insert_char(c);
app.selected_suggestion_idx = 0;
}
_ => {}
}
@ -621,9 +687,25 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
));
}
"/theme" => {
if let Some(arg) = parts.get(1) {
if *arg == "next" {
app.cycle_theme();
let theme_name = app.current_theme.clone();
app.add_system_message(format!("Theme set to: {theme_name}"));
return;
} else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) {
app.current_theme = name.to_string();
app.add_system_message(format!("Theme set to: {name}"));
return;
}
}
if let Some(pos) = app::THEMES
.iter()
.position(|(name, _)| *name == app.current_theme)
{
app.selected_theme_idx = pos;
}
app.mode = TuiMode::ThemeSelect;
}
"/settings" => {
app.add_system_message(format!(

View file

@ -1,4 +1,4 @@
use super::app::{AppState, MessageSender, TuiMode};
use super::app::{AppState, CommandSuggestion, MessageSender, TuiMode, THEMES};
use brokecrew::persona::Tier;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Position, Rect},
@ -8,150 +8,281 @@ use ratatui::{
Frame,
};
#[derive(Debug, Clone)]
pub struct ThemePalette {
pub accent: Color,
pub user_bg: Color,
pub card_border: Color,
pub tool_bg: Color,
pub text_bright: Color,
pub text_muted: Color,
}
pub fn get_theme_palette(theme: &str) -> ThemePalette {
match theme {
"warm" => ThemePalette {
accent: Color::Rgb(240, 160, 60), // warm amber
user_bg: Color::Rgb(48, 40, 36), // warm charcoal
card_border: Color::Rgb(100, 80, 60),
tool_bg: Color::Rgb(36, 32, 28),
text_bright: Color::Rgb(250, 245, 235),
text_muted: Color::Rgb(160, 145, 130),
},
"forest" => ThemePalette {
accent: Color::Rgb(80, 200, 140), // emerald mint
user_bg: Color::Rgb(30, 46, 38), // deep pine
card_border: Color::Rgb(60, 95, 75),
tool_bg: Color::Rgb(24, 38, 30),
text_bright: Color::Rgb(235, 250, 240),
text_muted: Color::Rgb(130, 165, 145),
},
"cyberpunk" => ThemePalette {
accent: Color::Rgb(255, 60, 150), // neon pink
user_bg: Color::Rgb(42, 25, 52), // midnight magenta
card_border: Color::Rgb(110, 50, 130),
tool_bg: Color::Rgb(30, 20, 40),
text_bright: Color::Rgb(255, 230, 250),
text_muted: Color::Rgb(160, 120, 180),
},
"mono" => ThemePalette {
accent: Color::White,
user_bg: Color::Rgb(38, 38, 38),
card_border: Color::Rgb(85, 85, 85),
tool_bg: Color::Rgb(28, 28, 28),
text_bright: Color::White,
text_muted: Color::DarkGray,
},
_ => ThemePalette {
// opencode (default)
accent: Color::Rgb(80, 210, 230), // cyan / sky
user_bg: Color::Rgb(46, 50, 66), // deep slate
card_border: Color::Rgb(70, 90, 115),
tool_bg: Color::Rgb(30, 38, 34),
text_bright: Color::White,
text_muted: Color::Rgb(140, 145, 160),
},
}
}
pub fn render(frame: &mut Frame, app: &mut AppState) {
let size = frame.area();
let palette = get_theme_palette(&app.current_theme);
if app.is_start_screen() {
render_start_screen(frame, app, size, &palette);
} else {
let root_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), // Minimal Header + divider
Constraint::Length(1), // Sleek 1-line top session tab
Constraint::Min(6), // Main workspace (Split: Chat left, Workings right)
Constraint::Length(1), // Minimal Bottom status / shortcuts
Constraint::Length(1), // Minimal Bottom status
])
.split(size);
render_header(frame, app, root_chunks[0]);
render_workspace(frame, app, root_chunks[1]);
render_footer(frame, app, root_chunks[2]);
render_header(frame, app, root_chunks[0], &palette);
render_workspace(frame, app, root_chunks[1], &palette);
render_footer(frame, app, root_chunks[2], &palette);
}
match app.mode {
TuiMode::PersonaSelect => render_persona_popup(frame, app),
TuiMode::PipelineSelect => render_pipeline_popup(frame, app),
TuiMode::SessionSelect => render_session_popup(frame, app),
TuiMode::PersonaSelect => render_persona_popup(frame, app, &palette),
TuiMode::PipelineSelect => render_pipeline_popup(frame, app, &palette),
TuiMode::SessionSelect => render_session_popup(frame, app, &palette),
TuiMode::ThemeSelect => render_theme_popup(frame, app, &palette),
TuiMode::Normal => {}
}
}
fn render_header(frame: &mut Frame, app: &AppState, area: Rect) {
let active_str = if app.is_auto_route() {
"auto".to_string()
fn render_start_screen(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(16), // Top spacer
Constraint::Length(6), // ASCII Logo
Constraint::Length(2), // Subtitle
Constraint::Length(1), // Spacer
Constraint::Length(3), // Centered input card
Constraint::Min(6), // Suggestions or shortcuts
Constraint::Length(1), // Footer
])
.split(area);
// 1. ASCII Logo (Compact block)
let width = area.width;
let logo_lines = if width >= 80 {
vec![
"██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗ ██████╗██████╗ ███████╗██╗ ██╗",
"██╔══██╗██╔══██╗██╔═══██╗██║ ██╔╝██╔════╝██╔════╝██╔══██╗██╔════╝██║ ██║",
"██████╔╝██████╔╝██║ ██║█████╔╝ █████╗ ██║ ██████╔╝█████╗ ██║ █╗ ██║",
"██╔══██╗██╔══██╗██║ ██║██╔═██╗ ██╔══╝ ██║ ██╔══██╗██╔══╝ ██║███╗██║",
"██████╔╝██║ ██║╚██████╔╝██║ ██╗███████╗╚██████╗██║ ██║███████╗╚███╔███╔╝",
"╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚══╝╚══╝ ",
]
} else {
app.current_persona_name().to_string()
vec![
" ___ _ ___ ",
" / _ )_______ / /_____ / _ \\_______ _ __ ",
" / _ / __/ _ \\/ '_/ -_) // / __/ -_) |/|/ / ",
"/____/_/ \\___/_/\\_\\\\__/\\___/_/ \\__/|__,__/ ",
"",
"",
]
};
let logo_spans: Vec<Line> = logo_lines
.into_iter()
.map(|l| {
Line::from(Span::styled(l, Style::default().fg(palette.accent).bold()))
.alignment(Alignment::Center)
})
.collect();
frame.render_widget(Paragraph::new(logo_spans), v_chunks[1]);
// 2. Subtitle
let subtitle = Line::from(vec![Span::styled(
"/help to view commands • v0.1.1",
Style::default().fg(palette.text_muted),
)])
.alignment(Alignment::Center);
frame.render_widget(Paragraph::new(subtitle), v_chunks[2]);
// 3. Centered input card
let h_card_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(60),
Constraint::Percentage(20),
])
.split(v_chunks[4]);
let input_area = h_card_chunks[1];
let input_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.card_border))
.style(Style::default().bg(palette.user_bg));
let input_inner = input_block.inner(input_area);
frame.render_widget(input_block, input_area);
let input_display = if app.input.is_empty() {
Line::from(vec![
Span::styled("▌ ", Style::default().fg(palette.accent)),
Span::styled(
"Ask anything or type / for commands...",
Style::default().fg(palette.text_muted).italic(),
),
])
} else {
Line::from(vec![
Span::styled("▌ ", Style::default().fg(palette.accent)),
Span::styled(&app.input, Style::default().fg(palette.text_bright).bold()),
])
};
frame.render_widget(Paragraph::new(input_display), input_inner);
// Terminal cursor positioning on start screen
let cursor_x = input_inner.x + 2 + (app.cursor_pos as u16);
let cursor_y = input_inner.y;
if cursor_x < input_inner.x + input_inner.width {
frame.set_cursor_position(Position {
x: cursor_x,
y: cursor_y,
});
}
// 4. Suggestions dropdown OR shortcut hint pills
let suggestions = app.get_matching_suggestions();
if !suggestions.is_empty() {
let sug_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(60),
Constraint::Percentage(20),
])
.split(v_chunks[5]);
render_suggestions_list(frame, app, sug_chunks[1], palette, &suggestions);
} else {
let hints = Line::from(vec![
Span::styled("Tab ", Style::default().fg(palette.accent).bold()),
Span::styled("persona • ", Style::default().fg(palette.text_muted)),
Span::styled("Ctrl+P ", Style::default().fg(palette.accent).bold()),
Span::styled("pipeline • ", Style::default().fg(palette.text_muted)),
Span::styled("/theme ", Style::default().fg(palette.accent).bold()),
Span::styled("color • ", Style::default().fg(palette.text_muted)),
Span::styled("Esc ", Style::default().fg(palette.accent).bold()),
Span::styled("quit", Style::default().fg(palette.text_muted)),
])
.alignment(Alignment::Center);
let hint_area = Rect {
x: v_chunks[5].x,
y: v_chunks[5].y + 1,
width: v_chunks[5].width,
height: 1,
};
frame.render_widget(Paragraph::new(hints), hint_area);
}
// 5. Footer
render_footer(frame, app, v_chunks[6], palette);
}
fn render_header(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let session_title = truncate_str(&app.active_session.title, 32);
let active_persona = app.current_persona_name();
let pipe_tag = if let Some(ref p) = app.active_pipeline_for_prompt {
Span::styled(format!(" [pipe: {p}]"), Style::default().fg(Color::Yellow))
format!(" [{p}]")
} else {
Span::raw("")
String::new()
};
let status_indicator = if app.is_generating {
let spinner_frames = ["-", "\\", "|", "/"];
let sp = spinner_frames[app.spinner_tick % spinner_frames.len()];
Span::styled(
format!(" {sp} thinking..."),
Style::default().fg(Color::Yellow),
)
} else if app.esc_pressed_at.is_some() {
Span::styled(
" [esc again to quit]",
Style::default().fg(Color::LightRed).bold(),
)
} else {
Span::styled(" * ready", Style::default().fg(Color::Green))
};
let session_badge = Span::styled(
format!(" [{}]", truncate_str(&app.active_session.title, 20)),
Style::default().fg(Color::DarkGray),
);
let left_spans = vec![
Span::styled("⠇ ", Style::default().fg(palette.accent).bold()),
Span::styled(
":: brokecrew",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
session_title,
Style::default().fg(palette.text_bright).bold(),
),
Span::raw(" "),
Span::styled(
format!("[{active_str}]"),
Style::default()
.fg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
),
pipe_tag,
session_badge,
Span::raw(" "),
status_indicator,
];
let min_color = if app.minute_requests >= 18 {
Color::Red
} else if app.minute_requests >= 14 {
Color::Yellow
} else {
Color::Green
};
let day_color = if app.daily_requests >= 45 {
Color::Red
} else if app.daily_requests >= 35 {
Color::Yellow
} else {
Color::Green
};
let right_spans = vec![
Span::styled(
format!("1m: {}/{}", app.minute_requests, app.minute_limit),
Style::default().fg(min_color),
),
Span::raw(" "),
Span::styled(
format!("24h: {}/{}", app.daily_requests, app.daily_limit),
Style::default().fg(day_color),
),
Span::raw(" "),
Span::styled(
format!("models: {}", app.models_cached),
Style::default().fg(Color::DarkGray),
format!(" ({active_persona}{pipe_tag})"),
Style::default().fg(palette.text_muted),
),
];
let right_spans = if app.is_generating {
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let sp = spinner_frames[app.spinner_tick % spinner_frames.len()];
vec![Span::styled(
format!("{sp} thinking..."),
Style::default().fg(Color::Yellow),
)]
} else if app.esc_pressed_at.is_some() {
vec![Span::styled(
"[esc again to quit]",
Style::default().fg(Color::LightRed).bold(),
)]
} else {
vec![]
};
let header_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
});
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(area);
frame.render_widget(Paragraph::new(Line::from(left_spans)), header_layout[0]);
frame.render_widget(
Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
header_layout[1],
);
// Subtle divider
let divider = Line::from(Span::styled(
"─".repeat(area.width as usize),
Style::default().fg(Color::DarkGray),
));
frame.render_widget(
Paragraph::new(divider),
Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: 1,
},
);
}
fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect) {
fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect, palette: &ThemePalette) {
let workspace_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
@ -160,11 +291,16 @@ fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect) {
])
.split(area);
render_conversation_pane(frame, app, workspace_chunks[0]);
render_workings_pane(frame, app, workspace_chunks[1]);
render_conversation_pane(frame, app, workspace_chunks[0], palette);
render_workings_pane(frame, app, workspace_chunks[1], palette);
}
fn render_conversation_pane(frame: &mut Frame, app: &mut AppState, area: Rect) {
fn render_conversation_pane(
frame: &mut Frame,
app: &mut AppState,
area: Rect,
palette: &ThemePalette,
) {
let chat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@ -176,34 +312,115 @@ fn render_conversation_pane(frame: &mut Frame, app: &mut AppState, area: Rect) {
])
.split(area);
render_minimal_messages(frame, app, chat_chunks[0]);
render_minimal_messages(frame, app, chat_chunks[0], palette);
// Top input border
let top_border = Line::from(Span::styled(
"─".repeat(chat_chunks[1].width as usize),
Style::default().fg(Color::Rgb(70, 90, 110)),
Style::default().fg(palette.card_border),
));
frame.render_widget(Paragraph::new(top_border), chat_chunks[1]);
// Input text line
render_minimal_input(frame, app, chat_chunks[2]);
render_minimal_input(frame, app, chat_chunks[2], palette);
// Bottom input border
let bot_border = Line::from(Span::styled(
"─".repeat(chat_chunks[3].width as usize),
Style::default().fg(Color::Rgb(70, 90, 110)),
Style::default().fg(palette.card_border),
));
frame.render_widget(Paragraph::new(bot_border), chat_chunks[3]);
// ~ indicator line
let tilde = Line::from(vec![
Span::raw(" "),
Span::styled("~", Style::default().fg(Color::DarkGray)),
Span::styled("~", Style::default().fg(palette.text_muted)),
]);
frame.render_widget(Paragraph::new(tilde), chat_chunks[4]);
// Suggestions popup when typing /
let suggestions = app.get_matching_suggestions();
if !suggestions.is_empty() {
render_suggestions_overlay(frame, app, chat_chunks[1], palette, &suggestions);
}
}
fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) {
fn render_suggestions_overlay(
frame: &mut Frame,
app: &AppState,
anchor: Rect,
palette: &ThemePalette,
suggestions: &[&'static CommandSuggestion],
) {
let count = suggestions.len().min(8);
let box_height = (count as u16) + 2;
let box_y = anchor.y.saturating_sub(box_height);
let sug_rect = Rect {
x: anchor.x + 1,
y: box_y,
width: anchor.width.saturating_sub(2).min(64),
height: box_height,
};
render_suggestions_list(frame, app, sug_rect, palette, suggestions);
}
fn render_suggestions_list(
frame: &mut Frame,
app: &AppState,
area: Rect,
palette: &ThemePalette,
suggestions: &[&'static CommandSuggestion],
) {
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.accent))
.style(Style::default().bg(Color::Rgb(26, 30, 42)));
let inner = block.inner(area);
frame.render_widget(block, area);
let count = suggestions.len().min(inner.height as usize);
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
.take(count)
.map(|(idx, sug)| {
let is_selected = idx == app.selected_suggestion_idx;
let (prefix, prefix_style) = if is_selected {
("→ ", Style::default().fg(palette.accent).bold())
} else {
(" ", Style::default().fg(Color::DarkGray))
};
let line = Line::from(vec![
Span::styled(prefix, prefix_style),
Span::styled(
format!("{:<13} ", sug.name),
if is_selected {
Style::default().fg(palette.accent).bold()
} else {
Style::default().fg(Color::White)
},
),
Span::styled(sug.description, Style::default().fg(palette.text_muted)),
]);
if is_selected {
ListItem::new(line).style(Style::default().bg(Color::Rgb(38, 44, 62)))
} else {
ListItem::new(line)
}
})
.collect();
let list = List::new(items);
frame.render_widget(list, inner);
}
fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let mut lines: Vec<Line> = Vec::new();
let full_width = area.width.saturating_sub(1) as usize;
@ -211,21 +428,59 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) {
match &msg.sender {
MessageSender::User => {
lines.push(Line::from(""));
let user_bg = Color::Rgb(46, 50, 66);
for text_line in msg.content.lines() {
let pad_len = full_width.saturating_sub(text_line.len() + 1);
// Top padding line
lines.push(
Line::from(vec![
Span::raw(" ").style(Style::default().bg(user_bg)),
Span::styled(
"▌ ",
Style::default().fg(palette.accent).bg(palette.user_bg),
),
Span::styled(
" ".repeat(full_width.saturating_sub(2)),
Style::default().bg(palette.user_bg),
),
])
.style(Style::default().bg(palette.user_bg)),
);
// Content lines
for text_line in msg.content.lines() {
let pad_len = full_width.saturating_sub(text_line.len() + 3);
lines.push(
Line::from(vec![
Span::styled(
"▌ ",
Style::default().fg(palette.accent).bg(palette.user_bg),
),
Span::styled(
text_line.to_string(),
Style::default().fg(Color::White).bg(user_bg),
Style::default()
.fg(palette.text_bright)
.bold()
.bg(palette.user_bg),
),
Span::styled(" ".repeat(pad_len), Style::default().bg(user_bg)),
Span::styled(" ".repeat(pad_len), Style::default().bg(palette.user_bg)),
])
.style(Style::default().bg(user_bg)),
.style(Style::default().bg(palette.user_bg)),
);
}
// Bottom padding line
lines.push(
Line::from(vec![
Span::styled(
"▌ ",
Style::default().fg(palette.accent).bg(palette.user_bg),
),
Span::styled(
" ".repeat(full_width.saturating_sub(2)),
Style::default().bg(palette.user_bg),
),
])
.style(Style::default().bg(palette.user_bg)),
);
lines.push(Line::from(""));
}
@ -239,7 +494,7 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) {
exit_code,
duration_ms,
} => {
let tool_bg = Color::Rgb(30, 38, 34);
let tool_bg = palette.tool_bg;
lines.push(Line::from(""));
@ -249,22 +504,29 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) {
let h_rem = full_width.saturating_sub(header_prefix.len() + timeout_tag.len());
lines.push(
Line::from(vec![
Span::styled(" $ ", Style::default().fg(Color::White).bold().bg(tool_bg)),
Span::styled(
command.clone(),
Style::default().fg(Color::White).bold().bg(tool_bg),
),
Span::raw(" ").style(Style::default().bg(tool_bg)),
Span::styled(
timeout_tag,
Style::default().fg(Color::Rgb(130, 140, 135)).bg(tool_bg),
header_prefix,
Style::default()
.fg(Color::Rgb(170, 220, 180))
.bold()
.bg(tool_bg),
),
Span::styled(" ".repeat(h_rem), Style::default().bg(tool_bg)),
Span::styled(
timeout_tag,
Style::default().fg(Color::Rgb(110, 130, 120)).bg(tool_bg),
),
])
.style(Style::default().bg(tool_bg)),
);
// Blank padding inside tool block
// Body content lines
let raw_content = msg.content.trim();
if !raw_content.is_empty() {
let content_lines: Vec<&str> = raw_content.lines().collect();
let total_c_lines = content_lines.len();
// Blank line inside tool box
lines.push(
Line::from(vec![Span::styled(
" ".repeat(full_width),
@ -273,29 +535,17 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) {
.style(Style::default().bg(tool_bg)),
);
let content_lines: Vec<&str> = msg.content.lines().collect();
if !content_lines.is_empty() {
if content_lines.len() > 6 && !app.expand_tool_outputs {
let earlier_count = content_lines.len() - 5;
let earlier_msg = format!(" ... ({earlier_count} earlier lines, ");
let e_rem = full_width.saturating_sub(earlier_msg.len() + 20);
if !app.expand_tool_outputs && total_c_lines > 6 {
let earlier_count = total_c_lines.saturating_sub(5);
let collapsed_msg =
format!(" ... ({earlier_count} earlier lines, ctrl+o to expand)");
let e_rem = full_width.saturating_sub(collapsed_msg.len());
lines.push(
Line::from(vec![
Span::styled(
earlier_msg,
collapsed_msg,
Style::default()
.fg(Color::Rgb(130, 140, 135))
.italic()
.bg(tool_bg),
),
Span::styled(
"ctrl+o",
Style::default().fg(Color::LightGreen).bold().bg(tool_bg),
),
Span::styled(
" to expand)",
Style::default()
.fg(Color::Rgb(130, 140, 135))
.fg(Color::Rgb(140, 150, 145))
.italic()
.bg(tool_bg),
),
@ -379,7 +629,7 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) {
Span::raw(" "),
Span::styled(
text_line.to_string(),
Style::default().fg(Color::Rgb(140, 140, 150)),
Style::default().fg(palette.text_muted),
),
]));
}
@ -501,15 +751,25 @@ fn parse_inline_spans(text: &str, spans: &mut Vec<Span<'static>>) {
}
}
fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) {
let para = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(&app.input, Style::default().fg(Color::White)),
]));
frame.render_widget(para, area);
fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let input_line = if app.input.is_empty() {
Line::from(vec![
Span::styled("▌ ", Style::default().fg(palette.accent)),
Span::styled(
"Ask anything or type / for commands...",
Style::default().fg(palette.text_muted).italic(),
),
])
} else {
Line::from(vec![
Span::styled("▌ ", Style::default().fg(palette.accent)),
Span::styled(&app.input, Style::default().fg(palette.text_bright)),
])
};
frame.render_widget(Paragraph::new(input_line), area);
// Terminal cursor positioning
let cursor_x = area.x + 1 + (app.cursor_pos as u16);
let cursor_x = area.x + 2 + (app.cursor_pos as u16);
let cursor_y = area.y;
if cursor_x < area.x + area.width {
frame.set_cursor_position(Position {
@ -519,13 +779,13 @@ fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) {
}
}
fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) {
fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(Color::DarkGray))
.border_style(Style::default().fg(palette.card_border))
.title(Span::styled(
" models routed ",
Style::default().fg(Color::DarkGray).bold(),
Style::default().fg(palette.text_muted).bold(),
));
let inner = block.inner(area);
@ -538,7 +798,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) {
Span::raw(" "),
Span::styled(
"waiting for prompt...",
Style::default().fg(Color::DarkGray).italic(),
Style::default().fg(palette.text_muted).italic(),
),
]));
} else {
@ -565,7 +825,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) {
model_display,
Style::default()
.fg(if att.is_success {
Color::White
palette.text_bright
} else {
Color::Gray
})
@ -591,7 +851,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) {
} else if att.is_success {
Style::default().fg(Color::LightGreen)
} else {
Style::default().fg(Color::DarkGray)
Style::default().fg(palette.text_muted)
},
),
]));
@ -603,7 +863,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) {
frame.render_widget(para, inner);
}
fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) {
fn render_footer(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let active_str = if app.is_auto_route() {
"(auto)".to_string()
} else {
@ -632,12 +892,12 @@ fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) {
"↑0.0k ↓0.0k 1m: {}/{} 24h: {}/{} {active_str}",
app.minute_requests, app.minute_limit, app.daily_requests, app.daily_limit
),
Style::default().fg(Color::DarkGray),
Style::default().fg(palette.text_muted),
)];
let right_spans = vec![Span::styled(
format!("{active_model} • {stealth_str}"),
Style::default().fg(Color::DarkGray),
Style::default().fg(palette.text_muted),
)];
let footer_layout = Layout::default()
@ -652,7 +912,7 @@ fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) {
);
}
fn render_persona_popup(frame: &mut Frame, app: &AppState) {
fn render_persona_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) {
let area = centered_rect(55, 55, frame.area());
frame.render_widget(Clear, area);
@ -669,7 +929,7 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) {
format!("{prefix}{:<12} ", item.name),
if is_selected {
Style::default()
.fg(Color::Cyan)
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(get_persona_color(&item.name))
@ -677,7 +937,7 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) {
),
Span::styled(
item.description.clone(),
Style::default().fg(Color::DarkGray),
Style::default().fg(palette.text_muted),
),
]);
@ -693,11 +953,11 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(palette.accent))
.title(Span::styled(
" select persona [up/down, enter select, esc cancel] ",
Style::default()
.fg(Color::Cyan)
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
)),
);
@ -705,7 +965,7 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) {
frame.render_widget(list, area);
}
fn render_pipeline_popup(frame: &mut Frame, app: &AppState) {
fn render_pipeline_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) {
let area = centered_rect(60, 50, frame.area());
frame.render_widget(Clear, area);
@ -735,7 +995,7 @@ fn render_pipeline_popup(frame: &mut Frame, app: &AppState) {
),
Span::styled(
format!("({steps_str})"),
Style::default().fg(Color::DarkGray),
Style::default().fg(palette.text_muted),
),
]);
@ -764,7 +1024,7 @@ fn render_pipeline_popup(frame: &mut Frame, app: &AppState) {
frame.render_widget(list, area);
}
fn render_session_popup(frame: &mut Frame, app: &AppState) {
fn render_session_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) {
let area = centered_rect(65, 55, frame.area());
frame.render_widget(Clear, area);
@ -783,7 +1043,7 @@ fn render_session_popup(frame: &mut Frame, app: &AppState) {
format!("{prefix}{:<24} ", truncate_str(&s.title, 24)),
if is_selected {
Style::default()
.fg(Color::Cyan)
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
@ -796,7 +1056,7 @@ fn render_session_popup(frame: &mut Frame, app: &AppState) {
s.message_count,
truncate_str(&s.updated_at, 19)
),
Style::default().fg(Color::DarkGray),
Style::default().fg(palette.text_muted),
),
]);
@ -813,11 +1073,69 @@ fn render_session_popup(frame: &mut Frame, app: &AppState) {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(palette.accent))
.title(Span::styled(
" saved sessions [up/down, enter resume, esc cancel] ",
Style::default()
.fg(Color::Cyan)
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
)),
);
frame.render_widget(list, area);
}
fn render_theme_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) {
let area = centered_rect(60, 48, frame.area());
frame.render_widget(Clear, area);
let items: Vec<ListItem> = THEMES
.iter()
.enumerate()
.map(|(idx, (name, desc))| {
let is_selected = idx == app.selected_theme_idx;
let is_active = *name == app.current_theme;
let prefix = if is_selected { "→ " } else { " " };
let mut spans = vec![
Span::styled(
format!("{prefix}{:<12} ", name),
if is_selected {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.text_bright)
},
),
Span::styled(*desc, Style::default().fg(palette.text_muted)),
];
if is_active {
spans.push(Span::styled(
" [active]",
Style::default().fg(Color::LightGreen).bold(),
));
}
let line = Line::from(spans);
if is_selected {
ListItem::new(line).style(Style::default().bg(Color::Rgb(32, 38, 54)))
} else {
ListItem::new(line)
}
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.accent))
.title(Span::styled(
" select theme [up/down, enter apply, esc cancel] ",
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
)),
);