feat(tui): OpenCode styling, start screen, suggestions popup, enlarged prompt card, and themes
This commit is contained in:
parent
eb4399177e
commit
7d6fd2d84f
3 changed files with 732 additions and 203 deletions
147
src/tui/app.rs
147
src/tui/app.rs
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
676
src/tui/ui.rs
676
src/tui/ui.rs
|
|
@ -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),
|
||||
)),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue