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

This commit is contained in:
2026-08-16 13:10:07 -04:00
parent 14f873bfdd
commit 79143e197d
+149 -48
View File
@@ -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<String> {
}
/// 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 <name>' 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 <name>' 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 <name>' 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,
&current_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 <name>' 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<Duration>) {
let color = app.color();
let sep = dim(" │ ", color);
let theme = app.theme();
let sep = dim(" │ ", theme, color);
let cwd = shorten_head(&current_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<Duration>) {
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<usize> = 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 <name>"));
}
#[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<usize> = text
.lines()
.map(|l| strip_ansi(l).chars().count())
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}");
}
#[test]