From 79143e197d2fb48a9a71eda402b8020e02ed55db Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 16 Aug 2026 13:10:07 -0400 Subject: [PATCH] v0.2.14 : splash screen theme - art ASCII et bordures aux couleurs du theme, section Theme (theme actif + liste des themes disponibles), ligne de statut theme --- src/repl.rs | 197 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 149 insertions(+), 48 deletions(-) diff --git a/src/repl.rs b/src/repl.rs index 73af56f..c162140 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -19,6 +19,7 @@ use crate::nav; use crate::ps; use crate::tables::DataTable; use crate::shell::ShellSession; +use crate::theme::Theme; use anyhow::{anyhow, Result}; use colored::Colorize; use rustyline::completion::{Completer, Pair}; @@ -443,7 +444,8 @@ pub fn banner_art() -> Vec { } /// The boxed banner: block art, Shell Gateway section (default shell -/// highlighted), command groups and the stats footer. +/// highlighted), the active color theme, command groups and the stats +/// footer. Every color comes from the active theme. pub fn banner_box( version: &str, agents: usize, @@ -452,6 +454,7 @@ pub fn banner_box( session: &ShellSession, session_id: &str, cwd: &str, + theme: &Theme, color: bool, ) -> String { let w = BOX_WIDTH; @@ -459,27 +462,41 @@ pub fn banner_box( let inner = |content: String| format!("│ {} │", pad_visible(content, w)); let blank = || inner(" ".to_string()); - // Top border with the centered header. - let header = format!(" Agent Manager v{version} · shell gateway "); - let fill = w + 2 - visible_len(&header); + // Top border with the centered header (title in the theme header color). + let title = format!(" Agent Manager v{version} · shell gateway "); + let title = if color { + theme.hdr(&title) + } else { + title + }; + let fill = w + 2 - visible_len(&title); let left = fill / 2; let right = fill - left; rows.push(dim( - &format!("╭{}{}{}╮", "─".repeat(left), header, "─".repeat(right)), + &format!("╭{}{}{}╮", "─".repeat(left), title, "─".repeat(right)), + theme, color, )); - // Block-art title. + // Block-art title, painted in the theme accent color. for art in banner_art() { - rows.push(inner(art)); + rows.push(inner(if color { + theme.acc(&art) + } else { + art + })); } // Shell gateway: the user's default shell is highlighted at startup. rows.push(blank()); - rows.push(inner(bold_cyan("Shell Gateway", color))); + rows.push(inner(if color { + theme.hdr("Shell Gateway") + } else { + "Shell Gateway".to_string() + })); let current = format!( "◉ {} — {}{}", - green(session.current.name, color), + green(session.current.name, theme, color), session.current.title, if session.is_default() { " (default)" } else { "" } ); @@ -495,15 +512,50 @@ pub fn banner_box( } else { others.join(" · ") }; - rows.push(inner(dim(&format!(" ⇄ {avail}"), color))); + rows.push(inner(dim(&format!(" ⇄ {avail}"), theme, color))); rows.push(inner(dim( " system commands run in the active shell — 'shell ' switches", + theme, + color, + ))); + + // Active color theme: the current one highlighted, every other theme + // listed, plus the two ways to switch. + rows.push(blank()); + rows.push(inner(if color { + theme.hdr("Theme") + } else { + "Theme".to_string() + })); + let current_theme = format!( + "◉ {} — {}", + green(theme.name, theme, color), + theme.title + ); + rows.push(inner(format!(" {current_theme}"))); + let theme_others: Vec<&str> = crate::theme::THEMES + .iter() + .map(|t| t.name) + .filter(|n| *n != theme.name) + .collect(); + rows.push(inner(dim( + &format!(" ⇄ {}", theme_others.join(" · ")), + theme, + color, + ))); + rows.push(inner(dim( + " 'am --theme ' to switch · settings.theme in the config", + theme, color, ))); // am command groups. rows.push(blank()); - rows.push(inner(bold_cyan("Commands", color))); + rows.push(inner(if color { + theme.hdr("Commands") + } else { + "Commands".to_string() + })); rows.push(inner(" catalog list · status · search · info".to_string())); rows.push(inner( " lifecycle install · uninstall · update · start · stop · restart · run".to_string(), @@ -520,9 +572,11 @@ pub fn banner_box( rows.push(blank()); rows.push(inner(dim( &format!( - "am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · /help for commands", - session.current.name + "am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · theme {} · /help for commands", + session.current.name, + theme.name ), + theme, color, ))); let prefix = format!(" session {session_id} · cwd "); @@ -530,9 +584,10 @@ pub fn banner_box( rows.push(inner(format!("{prefix}{}", shorten_head(cwd, budget)))); rows.push(inner(yellow( " ✦ Tip: 'shell ' switches the system shell · '!' forces a system command", + theme, color, ))); - rows.push(dim(&format!("╰{}╯", "─".repeat(w + 2)), color)); + rows.push(dim(&format!("╰{}╯", "─".repeat(w + 2)), theme, color)); rows.join("\n") } @@ -540,6 +595,7 @@ pub fn banner_box( /// The full startup banner (box + welcome + tip). pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String { let color = app.color(); + let theme = app.theme(); let mut text = banner_box( env!("CARGO_PKG_VERSION"), agent_count(app), @@ -548,6 +604,7 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String { session, session_id, ¤t_dir_string(), + theme, color, ); text.push('\n'); @@ -556,28 +613,30 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String { ); text.push_str(&format!( "{} {}\n", - yellow("✦ Tip:", color), + yellow("✦ Tip:", theme, color), dim( &format!( "system commands run in {} — 'shell ' switches · '!' forces · 'cd' works in this session", session.current.name ), + theme, color, ), )); text } -/// Color an emoji glyph (style names: green, blue, yellow, cyan). -fn emoji(glyph: &str, style: &str, color: bool) -> String { +/// Color an emoji glyph with the active theme (style names: green, blue, +/// yellow, cyan). +fn emoji(glyph: &str, style: &str, theme: &Theme, color: bool) -> String { if !color { return glyph.to_string(); } match style { - "green" => glyph.green().to_string(), - "blue" => glyph.blue().to_string(), - "yellow" => glyph.yellow().to_string(), - "cyan" => glyph.cyan().to_string(), + "green" => theme.acc(glyph), + "blue" => theme.inf(glyph), + "yellow" => theme.warn(glyph), + "cyan" => theme.inf(glyph), _ => glyph.to_string(), } } @@ -588,19 +647,24 @@ fn emoji(glyph: &str, style: &str, color: bool) -> String { /// the first one). fn print_status(app: &App, started: Instant, last: Option) { let color = app.color(); - let sep = dim(" │ ", color); + let theme = app.theme(); + let sep = dim(" │ ", theme, color); let cwd = shorten_head(¤t_dir_string(), 30); - let folder = format!("{} {}", emoji("🗂️", "cyan", color), dim(&cwd, color)); + let folder = format!( + "{} {}", + emoji("🗂️", "cyan", theme, color), + dim(&cwd, theme, color) + ); let counts = format!( "{} {} {} {} {} {} {} {}", - dim(&agent_count(app).to_string(), color), - emoji("🤖", "green", color), - dim("·", color), - dim(&external_count(app).to_string(), color), - emoji("🌐", "blue", color), - dim("·", color), - dim(&installed_count(app).to_string(), color), - emoji("📊", "yellow", color), + dim(&agent_count(app).to_string(), theme, color), + emoji("🤖", "green", theme, color), + dim("·", theme, color), + dim(&external_count(app).to_string(), theme, color), + emoji("🌐", "blue", theme, color), + dim("·", theme, color), + dim(&installed_count(app).to_string(), theme, color), + emoji("📊", "yellow", theme, color), ); let last_text = match last { Some(d) if d.as_secs() >= 1 => format_elapsed(d), @@ -608,11 +672,19 @@ fn print_status(app: &App, started: Instant, last: Option) { Some(d) => format!("{}µs", d.as_micros()), None => "—".to_string(), }; - let last_part = format!("{} {}", emoji("⚡", "yellow", color), dim(&last_text, color)); - let elapsed = dim(&format!("⏲ {}", format_elapsed(started.elapsed())), color); + let last_part = format!( + "{} {}", + emoji("⚡", "yellow", theme, color), + dim(&last_text, theme, color) + ); + let elapsed = dim( + &format!("⏲ {}", format_elapsed(started.elapsed())), + theme, + color, + ); println!( "{}am v{}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}", - dim(" ⚙ ", color), + dim(" ⚙ ", theme, color), env!("CARGO_PKG_VERSION"), ); } @@ -651,33 +723,25 @@ fn pad_visible(content: String, width: usize) -> String { } } -fn dim(s: &str, color: bool) -> String { +fn dim(s: &str, theme: &Theme, color: bool) -> String { if color { - s.dimmed().to_string() + theme.dimmed(s) } else { s.to_string() } } -fn bold_cyan(s: &str, color: bool) -> String { +fn green(s: &str, theme: &Theme, color: bool) -> String { if color { - s.cyan().bold().to_string() + theme.acc(s) } else { s.to_string() } } -fn green(s: &str, color: bool) -> String { +fn yellow(s: &str, theme: &Theme, color: bool) -> String { if color { - s.green().bold().to_string() - } else { - s.to_string() - } -} - -fn yellow(s: &str, color: bool) -> String { - if color { - s.yellow().to_string() + theme.warn(s) } else { s.to_string() } @@ -1440,6 +1504,7 @@ mod tests { &session, "20260815_123456_abcdef", "/home/alice/dev/agent-manager", + crate::theme::default_theme(), false, ); let widths: Vec = text.lines().map(|l| l.chars().count()).collect(); @@ -1453,6 +1518,42 @@ mod tests { assert!(text.contains("12 external")); assert!(text.contains("3 managed")); assert!(text.contains("agent-manager")); + // The splash screen shows the active theme and every other theme. + assert!(text.contains("Theme")); + assert!(text.contains("◉ default")); + assert!(text.contains("ocean")); + assert!(text.contains("sunset")); + assert!(text.contains("forest")); + assert!(text.contains("dracula")); + assert!(text.contains("mono")); + assert!(text.contains("theme default")); + assert!(text.contains("--theme ")); + } + + #[test] + fn banner_box_is_themed_and_still_aligned() { + let session = test_session(); + let ocean = crate::theme::find("ocean").unwrap(); + let text = banner_box( + "9.9.9", + 65, + 3, + 12, + &session, + "20260815_123456_abcdef", + "/home/alice/dev/agent-manager", + ocean, + true, + ); + // ANSI color codes are present with the ocean theme... + assert!(text.contains("\x1b["), "no ANSI in themed banner:\n{text}"); + assert!(strip_ansi(&text).contains("◉ ocean")); + // ...and the visible box stays aligned (strip the ANSI codes). + let widths: Vec = text + .lines() + .map(|l| strip_ansi(l).chars().count()) + .collect(); + assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}"); } #[test]