From 98c0b30f68720b3a1e5f87a0898d5fc104b48144 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 21:50:48 -0400 Subject: [PATCH] feat: am tip cheat sheet + readable man pages + REPL completion refresh --- src/cli.rs | 7 + src/commands/agents_cmd.rs | 20 +- src/commands/man_cmd.rs | 341 +++++++++++++++++++- src/commands/mod.rs | 2 + src/commands/tip_cmd.rs | 588 +++++++++++++++++++++++++++++++++++ src/help.rs | 29 +- src/repl.rs | 127 +++++++- src/tables.rs | 121 ++++--- tests/completion_man_test.rs | 156 +++++++++- tests/tip_test.rs | 74 +++++ 10 files changed, 1402 insertions(+), 63 deletions(-) create mode 100644 src/commands/tip_cmd.rs create mode 100644 tests/tip_test.rs diff --git a/src/cli.rs b/src/cli.rs index e9afd0f..2fcf3ef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -362,6 +362,13 @@ pub enum Command { }, /// Display version and build information Version, + /// Cheat sheet: the most useful commands, their key options, your + /// most-used commands and a rotating tip of the day + Tip { + /// Show a single random tip instead of the whole page + #[arg(long)] + random: bool, + }, /// Manage the configuration file #[command(subcommand)] Config(ConfigCmd), diff --git a/src/commands/agents_cmd.rs b/src/commands/agents_cmd.rs index bdd1e4c..07206b5 100644 --- a/src/commands/agents_cmd.rs +++ b/src/commands/agents_cmd.rs @@ -3,6 +3,7 @@ //! launched: installed agents (managed + external), aliases and groups. use super::*; +use std::collections::HashSet; /// The completion pool: installed agents (managed + external), aliases and /// groups when installed is true, the whole catalog otherwise. @@ -10,16 +11,29 @@ pub fn agent_names(app: &App, installed: bool) -> Vec { let mut names: Vec = Vec::new(); if installed { let probe = crate::probe::detect_externals(app); + let mut launchable: HashSet = HashSet::new(); for a in app.catalog.agents() { if app.state.get(&a.name).ok().flatten().is_some() || probe.path_of(&a.name).is_some() { + launchable.insert(a.name.clone()); names.push(a.name.clone()); } } - names.extend(app.config.aliases.keys().cloned()); - for g in app.config.groups.keys() { - names.push(format!("group:{g}")); + for (alias, target) in &app.config.aliases { + let is_launchable = app + .catalog + .resolve(alias) + .map(|agent| launchable.contains(&agent.name)) + .unwrap_or_else(|| launchable.contains(target)); + if is_launchable { + names.push(alias.clone()); + } + } + for (group, members) in &app.config.groups { + if members.iter().any(|m| launchable.contains(m)) { + names.push(format!("group:{group}")); + } } } else { names.extend(app.catalog.agents().iter().map(|a| a.name.clone())); diff --git a/src/commands/man_cmd.rs b/src/commands/man_cmd.rs index 887909d..deb5be8 100644 --- a/src/commands/man_cmd.rs +++ b/src/commands/man_cmd.rs @@ -1,11 +1,15 @@ //! man: generate roff man pages from the clap definition (issue #41). //! 'am man' prints am.1 for the whole CLI; 'am man ' prints one //! page; '--output DIR' writes am.1 plus one am-.1 per command. +//! +//! On a terminal the page is rendered to readable, themed text (there is no +//! man viewer on Windows); when stdout is piped the raw roff source is kept +//! so 'am man | man -l -' and scripts keep working unchanged. use super::*; use anyhow::Context; use clap::CommandFactory; -use std::io::Write; +use std::io::{IsTerminal, Write}; use std::path::Path; pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result { @@ -30,7 +34,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result man.render(&mut std::io::stdout())?, + None => print_page(app, &man)?, } } None => match output { @@ -51,7 +55,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result clap_mangen::Man::new(cmd).render(&mut std::io::stdout())?, + None => print_page(app, &clap_mangen::Man::new(cmd))?, }, } Ok(0) @@ -68,3 +72,334 @@ fn write_page(dir: &Path, name: &str, man: &clap_mangen::Man) -> Result<()> { file.flush().ok(); Ok(()) } + +/// Print one man page: raw roff when stdout is piped (scripts, 'man -l -'), +/// a readable themed rendering when it is a terminal. +fn print_page(app: &App, man: &clap_mangen::Man) -> Result<()> { + let mut buf: Vec = Vec::new(); + man.render(&mut buf) + .context("cannot render the man page")?; + let roff = String::from_utf8_lossy(&buf); + if std::io::stdout().is_terminal() { + print!("{}", roff_to_text(&roff, app.theme(), app.color())); + } else { + std::io::stdout().write_all(roff.as_bytes())?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Miniature roff viewer: renders the groff man macro subset emitted by +// clap_mangen (.TH .SH .SS .PP .TP .IP .B .I .BR .RB .BI .IB .IR .RI .RS +// .RE .nf .fi ...) to plain, themed text. +// --------------------------------------------------------------------------- + +const IND_TERM: &str = " "; +const IND_BODY: &str = " "; + +/// Convert the roff source of a man page into readable text, coloured with +/// the active theme when color is on. +pub fn roff_to_text(roff: &str, theme: &crate::theme::Theme, color: bool) -> String { + let mut out = String::new(); + let mut level = 0usize; // .RS/.RE nesting + let mut in_tp = false; // inside a .TP definition list + let mut tp_term: Option = None; // the pending term of a .TP + let mut tp_printed = false; // term emitted, further lines are bodies + for raw in roff.lines() { + let line = raw.trim_end(); + let trimmed = line.trim_start(); + if trimmed.is_empty() || trimmed.starts_with(".\\\"") { + continue; + } + // Groff conditionals defining \*(Aq — not needed by the renderer. + if trimmed.starts_with(".ie ") || trimmed.starts_with(".el ") { + continue; + } + if let Some(rest) = trimmed.strip_prefix('.') { + let (name, args) = match rest.split_once(char::is_whitespace) { + Some((n, a)) => (n, a.trim()), + None => (rest, ""), + }; + // Any other macro closes a pending .TP list. + if let Some(term) = tp_term.take() { + out.push_str(&format!("{IND_TERM}{term}\n")); + } + in_tp = false; + tp_printed = false; + match name { + "TH" => { + let mut parts = args.split_whitespace(); + let title = parts.next().unwrap_or("am"); + let sec = parts.next().unwrap_or("1"); + let meta = parts.collect::>().join(" "); + let meta = meta.trim_matches('"').to_string(); + let mut line = theme.acc(&format!("{title}({sec})")); + if !meta.is_empty() { + line.push_str(&format!(" {}", theme.dimmed(&meta))); + } + out.push_str(&format!("{line}\n\n")); + } + "SH" => { + out.push('\n'); + let title = conv(args, theme, color); + out.push_str(&format!( + "{}\n", + if color { + theme.hdr(&format!("▸ {title}")) + } else { + format!("▸ {title}") + } + )); + } + "SS" => { + out.push('\n'); + let title = conv(args, theme, color); + out.push_str(&format!( + "{}\n", + if color { + theme.inf(&format!("· {title}")) + } else { + format!("· {title}") + } + )); + } + "PP" | "P" | "sp" => out.push('\n'), + "br" => out.push('\n'), + "TP" => { + in_tp = true; + tp_term = None; + tp_printed = false; + } + "IP" => { + // Indented paragraph, often a bullet (see also lists). + in_tp = true; + tp_printed = true; + out.push_str(&format!("{IND_TERM}{}\n", conv(args, theme, color))); + } + "B" => out.push_str(&format!("{IND_TERM}{}\n", bold(conv(args, theme, color), color))), + "I" => out.push_str(&format!( + "{IND_TERM}{}\n", + if color { + theme.inf(&conv(args, theme, color)) + } else { + conv(args, theme, color) + } + )), + "BR" | "RB" | "BI" | "IB" | "IR" | "RI" => { + let mut parts = args.splitn(2, char::is_whitespace); + let a = parts.next().unwrap_or(""); + let b = parts.next().unwrap_or(""); + let (bold_first, italic_second) = match name { + "BR" | "BI" | "IB" | "IR" => (true, matches!(name, "BI" | "IB")), + _ => (false, false), + }; + let left = if bold_first { + bold(conv(a, theme, color), color) + } else { + conv(a, theme, color) + }; + let right = if italic_second { + if color { + theme.inf(&conv(b, theme, color)) + } else { + conv(b, theme, color) + } + } else { + conv(b, theme, color) + }; + out.push_str(&format!("{IND_TERM}{left} {right}\n")); + } + "RS" => level += 1, + "RE" => level = level.saturating_sub(1), + // Filling/formatting directives that do not affect this + // line-based rendering. + "nf" | "fi" | "ad" | "hy" | "in" | "ds" | "EX" | "EE" => {} + other => { + // Unknown macro: keep its arguments as plain text when + // there is something to show. + if !args.is_empty() { + out.push_str(&format!("{}{}\n", indent(level), conv(args, theme, color))); + } + let _ = other; + } + } + continue; + } + // Plain text line: the first line after a .TP is the term, the + // following ones its body. The term is emitted as soon as the + // first body line arrives, so terms and bodies stay in order. + let text = conv(trimmed, theme, color); + if in_tp && !tp_printed { + if tp_term.is_none() { + tp_term = Some(text); + } else { + if let Some(term) = tp_term.take() { + out.push_str(&format!("{IND_TERM}{term}\n")); + } + out.push_str(&format!("{IND_BODY}{text}\n")); + tp_printed = true; + } + } else if in_tp { + out.push_str(&format!("{IND_BODY}{text}\n")); + } else { + out.push_str(&format!("{}{}\n", indent(level), text)); + } + } + if let Some(term) = tp_term.take() { + out.push_str(&format!("{IND_TERM}{term}\n")); + } + out +} + +/// Indentation for .RS/.RE nesting (plus a small base margin). +fn indent(level: usize) -> String { + format!("{}{}", " ".repeat(level), " ") +} + +/// Bold text: ANSI bold when colors are on, plain otherwise. +fn bold(s: String, color: bool) -> String { + if color { + format!("\x1b[1m{s}\x1b[0m") + } else { + s + } +} + +/// Convert roff inline escapes to text: \\fB/\\fI/\\fR font switches, +/// \\*(Aq, \\- (hyphen), \\(bu (bullet), \\& and friends. +fn conv(s: &str, theme: &crate::theme::Theme, color: bool) -> String { + let mut out = String::new(); + let chars: Vec = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] != '\\' { + out.push(chars[i]); + i += 1; + continue; + } + let Some(next) = chars.get(i + 1).copied() else { + out.push('\\'); + break; + }; + match next { + 'f' => { + let mode = chars.get(i + 2).copied().unwrap_or('P'); + if color { + match mode { + 'B' => out.push_str("\x1b[1m"), + 'I' => out.push_str(&format!("\x1b[{}m", theme.info)), + _ => out.push_str("\x1b[0m"), + } + } + i += 3; + } + '(' => { + let a = chars.get(i + 2).copied().unwrap_or(' '); + let d = chars.get(i + 3).copied().unwrap_or(' '); + let two = format!("{a}{d}"); + out.push_str(match two.as_str() { + "aq" => "'", + "bu" => "•", + "em" => "—", + "lq" => "\u{201c}", + "rq" => "\u{201d}", + _ => two.as_str(), + }); + i += 4; + } + '*' => { + // \*(xx : the '(' opens the two-character string name. + let a = chars.get(i + 3).copied().unwrap_or(' '); + let d = chars.get(i + 4).copied().unwrap_or(' '); + let name = format!("{a}{d}"); + out.push_str(match name.as_str() { + "Aq" | "Sq" => "'", + _ => "", + }); + i += 5; + } + '-' => { + out.push('-'); + i += 2; + } + '&' => i += 2, + 'e' => { + out.push('\\'); + i += 2; + } + ' ' => { + out.push(' '); + i += 2; + } + '\\' => { + out.push('\\'); + i += 2; + } + _ => { + out.push('\\'); + i += 1; + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::default_theme; + + fn plain(roff: &str) -> String { + roff_to_text(roff, default_theme(), false) + } + + #[test] + fn renders_sections_terms_and_bodies() { + let text = plain( + ".TH am 1 \"am 0.4.3\"\n\ + .SH NAME\n\ + am \\- agent manager\n\ + .SH OPTIONS\n\ + .TP\n\ + \\fB\\-c\\fR, \\fB\\-\\-config\\fR \\fI\\fR\n\ + Use an alternate configuration file\n", + ); + assert!(text.contains("am(1)"), "{text}"); + assert!(text.contains("▸ NAME"), "{text}"); + assert!(text.contains("▸ OPTIONS"), "{text}"); + assert!(text.contains("am - agent manager"), "{text}"); + assert!(text.contains("--config"), "{text}"); + assert!(text.contains("Use an alternate configuration file"), "{text}"); + assert!(!text.contains("\\fB"), "font escapes must be converted: {text}"); + assert!(!text.contains(".SH"), "macros must be removed: {text}"); + } + + #[test] + fn converts_special_characters() { + let t = default_theme(); + assert_eq!(conv("\\*(Aqhello\\*(Aq", t, false), "'hello'"); + assert_eq!(conv("\\-\\-dry\\-run", t, false), "--dry-run"); + assert_eq!(conv("\\(bu", t, false), "•"); + assert_eq!(conv("\\&x", t, false), "x"); + assert_eq!(conv("a\\e\\eb", t, false), "a\\\\b"); + } + + #[test] + fn colors_bold_and_italic_segments_when_enabled() { + let t = default_theme(); + let s = conv("\\fBam\\fR \\fIvalue\\fR", t, true); + assert!(s.contains("\x1b[1m"), "{s}"); + assert!(s.contains(&format!("\x1b[{}m", t.info)), "{s}"); + assert!(s.contains("\x1b[0m"), "{s}"); + // Without colors the same text is plain. + assert_eq!(conv("\\fBam\\fR \\fIvalue\\fR", t, false), "am value"); + } + + #[test] + fn tp_term_is_flushed_at_the_next_macro() { + let text = plain(".TP\n\\fBam\\fR\nbody line\n.PP\nnext paragraph\n"); + assert!(text.contains(" am\n"), "{text}"); + assert!(text.contains(" body line"), "{text}"); + assert!(text.contains("next paragraph"), "{text}"); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e8cc59a..4ca9623 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -29,6 +29,7 @@ pub mod stats_cmd; pub mod self_update; pub mod status_cmd; pub mod timeline_cmd; +pub mod tip_cmd; pub mod uninstall_cmd; pub mod update_cmd; pub mod watch_cmd; @@ -161,6 +162,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Init { force } => init_cmd::run(app, *force), Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()), Command::Version => version_cmd::run(app), + Command::Tip { random } => tip_cmd::run(app, *random), Command::Config(sub) => config_cmd::run(app, sub), Command::Doctor { fix } => doctor_cmd::run(app, *fix), Command::Run { agent, args } => run_cmd::run(app, agent, args), diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs new file mode 100644 index 0000000..38c81c1 --- /dev/null +++ b/src/commands/tip_cmd.rs @@ -0,0 +1,588 @@ +//! tip: a cheat sheet of the most useful am commands with their key +//! options — plus the user's own most-used commands (from the REPL +//! history) and a rotating "did you know" tip. +//! +//! 'am tip' prints the whole page; 'am tip --random' prints a single tip; +//! 'am tip --json' emits the same content as structured JSON. + +use super::*; +use chrono::Datelike; +use crate::history::HistoryEntry; +use crate::theme::Theme; +use serde_json::json; + +/// One cheat-sheet entry: usage line, one-line purpose, key options and a +/// concrete example. +pub struct TipEntry { + pub usage: &'static str, + pub about: &'static str, + pub options: &'static [(&'static str, &'static str)], + pub example: &'static str, +} + +/// One section of the cheat sheet (icon + title + entries). +pub struct TipSection { + pub icon: &'static str, + pub title: &'static str, + pub entries: &'static [TipEntry], +} + +/// The curated cheat sheet, ordered from everyday actions to expert tools. +pub static SECTIONS: &[TipSection] = &[ + TipSection { + icon: "🚀", + title: "Lancer & piloter", + entries: &[ + TipEntry { + usage: "start ", + about: "démarre un agent (premier plan par défaut)", + options: &[ + ("-b, --background", "détaché en arrière-plan, sortie dans le log"), + ("--args \"...\"", "arguments supplémentaires passés à l'agent"), + ("--env KEY=VALUE", "variable d'environnement (répétable)"), + ("--profile ", "profil d'environnement (env + args)"), + ], + example: "start claude-code --background", + }, + TipEntry { + usage: "stop ", + about: "arrête un agent en arrière-plan (SIGTERM puis SIGKILL)", + options: &[ + ("--force", "kill immédiat, sans délai de grâce"), + ("--timeout ", "délai de grâce avant le kill (défaut : 5 s)"), + ], + example: "stop pi", + }, + TipEntry { + usage: "restart ", + about: "redémarre avec les mêmes options (agent, groupe, profil…)", + options: &[], + example: "restart group:dev", + }, + TipEntry { + usage: "watch ", + about: "supervise l'agent et le relance s'il meurt", + options: &[ + ("--restart", "relance automatique avec backoff"), + ("--notify", "notification bureau à chaque relance"), + ], + example: "watch smelt --restart", + }, + TipEntry { + usage: "run ", + about: "exécute la commande de l'agent directement (sans gestion de processus)", + options: &[("--args \"...\"", "arguments transmis tels quels à l'agent")], + example: "run claude-code --version", + }, + ], + }, + TipSection { + icon: "📦", + title: "Installer & maintenir", + entries: &[ + TipEntry { + usage: "list", + about: "agents installés (managed + external détectés sur le PATH)", + options: &[ + ("--all", "catalogue complet, y compris non installés"), + ("--running", "uniquement les agents actifs"), + ("--sort ", "name, version ou status"), + ("--category ", "filtre par catégorie"), + ], + example: "list --all", + }, + TipEntry { + usage: "search ", + about: "cherche dans le catalogue (nom, description, catégorie, tags)", + options: &[], + example: "search pi", + }, + TipEntry { + usage: "install ", + about: "installe l'agent et ses dépendances (Node, Python, Rust…)", + options: &[ + ("--method ", "choisit la méthode (index ou type:npm, binary…)"), + ("--force", "réinstalle même si déjà présent"), + ], + example: "install pi", + }, + TipEntry { + usage: "update ", + about: "met à jour un agent vers la dernière version disponible", + options: &[("--all", "tous les agents installés d'un coup")], + example: "update --all", + }, + TipEntry { + usage: "uninstall ", + about: "supprime les fichiers et l'entrée d'état (l'autocomplétion s'actualise)", + options: &[("--purge", "supprime aussi les logs et la définition de la config")], + example: "uninstall pi", + }, + ], + }, + TipSection { + icon: "👁️", + title: "Observer", + entries: &[ + TipEntry { + usage: "status", + about: "état de tous les agents (running / installed / external)", + options: &[], + example: "status", + }, + TipEntry { + usage: "logs ", + about: "lit la fin du journal de l'agent", + options: &[ + ("--follow", "suit le log en direct"), + ("--lines ", "nombre de lignes (défaut : 20)"), + ], + example: "logs pi --follow", + }, + TipEntry { + usage: "dashboard", + about: "TUI en direct : activité, stats, sessions, projets", + options: &[], + example: "dashboard", + }, + TipEntry { + usage: "sessions · stats · top", + about: "historique des sessions · statistiques · agents les plus utilisés", + options: &[("--period

", "7d, 30d, 90d ou all")], + example: "top --period 30d", + }, + TipEntry { + usage: "timeline", + about: "une vue chronologique de toute l'activité", + options: &[("--since ", "activité à partir d'une date")], + example: "timeline --since 2026-08-01", + }, + ], + }, + TipSection { + icon: "⚙️", + title: "Personnaliser", + entries: &[ + TipEntry { + usage: "alias add ", + about: "crée un raccourci (ex. cc → claude-code)", + options: &[], + example: "alias add cc claude-code", + }, + TipEntry { + usage: "profile", + about: "profils d'environnement réutilisables (env + args)", + options: &[], + example: "start pi --profile dev", + }, + TipEntry { + usage: "config show", + about: "configuration effective (catalogue fusionné, alias, groupes…)", + options: &[], + example: "config show", + }, + TipEntry { + usage: "theme ", + about: "change la palette du shell, des tableaux et de cette page", + options: &[], + example: "theme ocean", + }, + TipEntry { + usage: "secret set --agent --value ", + about: "stocke un secret dans le keyring de l'OS, jamais en clair", + options: &[], + example: "secret set API_KEY --agent pi --value sk-…", + }, + TipEntry { + usage: "favorite · note · tag", + about: "annotez vos agents : étoile, note libre, tags personnels", + options: &[], + example: "tag pi perso", + }, + ], + }, + TipSection { + icon: "🧰", + title: "Boîte à outils", + entries: &[ + TipEntry { + usage: "doctor --fix", + about: "diagnostique l'environnement et répare les problèmes courants", + options: &[], + example: "doctor --fix", + }, + TipEntry { + usage: "export · import", + about: "sauvegarde config + état, puis restauration", + options: &[("--output ", "fichier de destination (défaut : am-export.json)")], + example: "export --output backup.json", + }, + TipEntry { + usage: "completion --installed", + about: "complétion dynamique : agents installés, alias et groupes au Tab", + options: &[], + example: "completion powershell --installed", + }, + TipEntry { + usage: "man ", + about: "page de manuel pour une commande", + options: &[], + example: "man start", + }, + TipEntry { + usage: "help --find ", + about: "cherche une commande dans toute l'aide", + options: &[], + example: "help --find log", + }, + TipEntry { + usage: "self-update", + about: "met à jour agent-manager lui-même", + options: &[("--check", "vérifie seulement si une version existe")], + example: "self-update --check", + }, + ], + }, +]; + +/// Rotating pro tips ("Le saviez-vous ?"). +const DID_YOU_KNOW: &[(&str, &str)] = &[ + ( + "Tab complète vos agents", + "dans le shell am, tapez 'start ' puis Tab : agents installés, alias et groupes s'affichent — et la liste se met à jour après chaque install/uninstall.", + ), + ( + "!! relance la dernière commande", + "l'historique du shell accepte !!, !N et !prefix pour rejouer une commande sans la retaper.", + ), + ( + "Le shell am double le shell système", + "tout ce qui n'est pas une commande am est exécuté par votre shell (pwsh, cmd, bash…) : pas besoin de sortir pour lancer un outil.", + ), + ( + "--dry-run simule sans rien changer", + "testez 'install', 'update' ou 'uninstall' en toute sécurité : am affiche ce qu'il ferait, sans toucher au disque.", + ), + ( + "Les groupes lancent plusieurs agents", + "'start group:dev' démarre tout le groupe d'un coup ; créez les vôtres dans la config.", + ), + ( + "Cherchez dans toute l'aide", + "'am help --find ' parcourt descriptions, usages et exemples de toutes les commandes.", + ), + ( + "--json pour les scripts", + "quasiment toutes les commandes acceptent --json et sortent des données structurées, parfaites pour vos scripts.", + ), + ( + "Les alias raccourcissent tout", + "'alias add cc claude-code' puis 'start cc' : un nom court pour chaque agent.", + ), + ( + "export/import sauvegardent votre monde", + "config, état, annotations et profils partent dans un fichier JSON, restaurables avec 'import'.", + ), + ( + "Les secrets vivent dans le keyring", + "'secret set API_KEY --agent pi' stocke la valeur dans le trousseau de l'OS, jamais en clair dans la config.", + ), + ( + "Le thème suit votre humeur", + "'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.", + ), + ( + "doctor répare", + "'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).", + ), +]; + +/// Entry point: print the cheat sheet page (or a single random tip). +pub fn run(app: &App, random: bool) -> Result { + print!("{}", render(app, random)); + Ok(0) +} + +/// Render the page as text (or JSON with --json). +pub fn render(app: &App, random: bool) -> String { + if app.json() { + return render_json(app, random); + } + let p = Painter { + theme: app.theme(), + color: app.color(), + }; + if random { + let (title, body) = random_tip(); + return format!("💡 {} — {}\n", p.acc(title), body); + } + let mut out = String::new(); + out.push_str(&header(app, &p)); + out.push('\n'); + for s in SECTIONS { + out.push_str(§ion_rule(app, &p, s.icon, s.title)); + out.push('\n'); + for e in s.entries { + out.push_str(&entry(&p, e)); + } + out.push('\n'); + } + out.push_str(&most_used_section(app, &p)); + out.push('\n'); + let (title, body) = daily_tip(); + out.push_str(§ion_rule(app, &p, "💡", "Le saviez-vous ?")); + out.push('\n'); + out.push_str(&format!(" {} — {}\n", p.acc(title), body)); + out.push('\n'); + out.push_str(&format!( + " {}\n", + p.dim("am help pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'") + )); + out +} + +// --------------------------------------------------------------------------- +// Rendering helpers +// --------------------------------------------------------------------------- + +/// Width of the cheat sheet, following the terminal (capped). +fn page_width(app: &App) -> usize { + crate::output::terminal_width() + .unwrap_or(100) + .clamp(50, 110) +} + +/// Tiny themed painter that honours the global --no-color flag. +struct Painter<'a> { + theme: &'a Theme, + color: bool, +} + +impl Painter<'_> { + fn paint(&self, f: fn(&Theme, &str) -> String, s: &str) -> String { + if self.color { + f(self.theme, s) + } else { + s.to_string() + } + } + fn acc(&self, s: &str) -> String { + self.paint(Theme::acc, s) + } + fn inf(&self, s: &str) -> String { + self.paint(Theme::inf, s) + } + fn dim(&self, s: &str) -> String { + self.paint(Theme::dimmed, s) + } + fn hdr(&self, s: &str) -> String { + self.paint(Theme::hdr, s) + } + fn frame(&self, s: &str) -> String { + self.paint(Theme::frame, s) + } + fn val(&self, s: &str) -> String { + self.paint(Theme::val, s) + } +} + +/// Title box: a framed header with a subtitle line. +fn header(app: &App, p: &Painter) -> String { + let w = page_width(app); + let title = " 🧠 TIP · votre antisèche de commandes am "; + let pad = w.saturating_sub(title.chars().count() + 2).max(1); + let top = format!("╭─{title}{}╮", "─".repeat(pad)); + let sub = " les commandes les plus utiles · vos habitudes · une astuce du jour"; + let subpad = w.saturating_sub(sub.chars().count() + 2).max(1); + let mid = format!("│{sub}{}│", " ".repeat(subpad)); + let bot = format!("╰{}╯", "─".repeat(w.saturating_sub(2).max(2))); + format!( + "{}\n{}\n{}\n", + p.frame(&top), + p.dim(&mid), + p.frame(&bot) + ) +} + +/// Section separator: " 🚀 Lancer & piloter ────────". +fn section_rule(app: &App, p: &Painter, icon: &str, title: &str) -> String { + let w = page_width(app); + let label = format!(" {icon} {title} "); + let dashes = "─".repeat(w.saturating_sub(label.chars().count()).max(1)); + format!("{}{}", p.hdr(&label), p.frame(&dashes)) +} + +/// One cheat-sheet entry: usage line, options, example. +fn entry(p: &Painter, e: &TipEntry) -> String { + let mut out = String::new(); + out.push_str(&format!(" {} {} {}\n", p.inf("▸"), p.acc(e.usage), e.about)); + for (flag, desc) in e.options { + out.push_str(&format!(" {} {}\n", p.dim(flag), desc)); + } + if !e.example.is_empty() { + out.push_str(&format!(" {} {}\n", p.dim("❯"), p.val(e.example))); + } + out +} + +/// Count the am commands of a history (first token per entry), most used +/// first, truncated to 8. Pure and testable. +pub fn count_commands(entries: &[HistoryEntry]) -> Vec<(String, usize)> { + let mut counts: BTreeMap = BTreeMap::new(); + for e in entries { + if e.kind != "am" { + continue; + } + if let Some(first) = e.cmd.split_whitespace().next() { + if !first.is_empty() { + *counts.entry(first.to_string()).or_default() += 1; + } + } + } + let mut v: Vec<(String, usize)> = counts.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v.truncate(8); + v +} + +/// The user's most-used commands, from the REPL history. +fn most_used(app: &App) -> Vec<(String, usize)> { + count_commands(&crate::history::read_all(app, 0)) +} + +/// A 10-cell usage bar: ▰ for used cells, ▱ for the rest. +fn usage_bar(count: usize, max: usize) -> String { + if max == 0 { + return "▱".repeat(10); + } + let filled = (count * 10 + max / 2) / max; + format!( + "{}{}", + "▰".repeat(filled.min(10)), + "▱".repeat(10usize.saturating_sub(filled.min(10))) + ) +} + +/// "🔥 Vos commandes les plus utilisées": a little bar chart from the +/// REPL history, so the page grows with the user. +fn most_used_section(app: &App, p: &Painter) -> String { + let top = most_used(app); + let mut out = String::new(); + out.push_str(§ion_rule(app, p, "🔥", "Vos commandes les plus utilisées")); + out.push('\n'); + if top.is_empty() { + out.push_str(&format!( + " {} — lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici.\n", + p.dim("aucune commande enregistrée") + )); + return out; + } + let max = top[0].1; + let name_w = top.iter().map(|(n, _)| n.chars().count()).max().unwrap_or(0).min(14); + for (name, count) in &top { + out.push_str(&format!( + " {} {} {}×\n", + p.acc(&format!("{name: (&'static str, &'static str) { + let day = chrono::Local::now().ordinal() as usize; + DID_YOU_KNOW[day % DID_YOU_KNOW.len()] +} + +/// Random tip for '--random'. +fn random_tip() -> (&'static str, &'static str) { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos() as usize) + .unwrap_or(0); + DID_YOU_KNOW[nanos % DID_YOU_KNOW.len()] +} + +/// The same content as structured JSON (--json). +fn render_json(app: &App, random: bool) -> String { + let (title, body) = if random { random_tip() } else { daily_tip() }; + let sections: Vec = SECTIONS + .iter() + .map(|s| { + json!({ + "icon": s.icon, + "title": s.title, + "entries": s.entries.iter().map(|e| json!({ + "usage": e.usage, + "about": e.about, + "options": e.options.iter().map(|(flag, desc)| json!({ "flag": flag, "desc": desc })).collect::>(), + "example": e.example, + })).collect::>(), + }) + }) + .collect(); + let payload = json!({ + "sections": sections, + "most_used": most_used(app).iter().map(|(command, count)| json!({ "command": command, "count": count })).collect::>(), + "tip": { "title": title, "body": body }, + }); + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(cmd: &str, kind: &str) -> HistoryEntry { + HistoryEntry::now(cmd.to_string()).with_kind(kind.to_string()) + } + + #[test] + fn counts_only_am_commands_by_first_token() { + let entries = vec![ + entry("start pi", "am"), + entry("start pi", "am"), + entry("list", "am"), + entry("git status", "shell"), + entry("start claude-code", "am"), + entry("", "am"), + ]; + let top = count_commands(&entries); + assert_eq!(top[0], ("start".to_string(), 3)); + assert_eq!(top[1], ("list".to_string(), 1)); + assert_eq!(top.len(), 2, "shell commands and empty lines are ignored"); + } + + #[test] + fn empty_history_yields_no_commands() { + assert!(count_commands(&[]).is_empty()); + } + + #[test] + fn usage_bars_fill_and_cap_at_ten_cells() { + assert_eq!(usage_bar(10, 10), "▰".repeat(10)); + assert_eq!(usage_bar(0, 10), "▱".repeat(10)); + assert_eq!(usage_bar(5, 10), "▰".repeat(5) + &"▱".repeat(5)); + // A count larger than the max still fits the bar. + assert_eq!(usage_bar(99, 10).chars().count(), 10); + } + + #[test] + fn daily_tip_is_always_in_bounds() { + let (t, b) = daily_tip(); + assert!(DID_YOU_KNOW.iter().any(|(x, y)| *x == t && *y == b)); + } + + #[test] + fn sections_are_non_empty_and_consistent() { + for s in SECTIONS { + assert!(!s.entries.is_empty(), "section {} has no entries", s.title); + for e in s.entries { + assert!(!e.usage.is_empty()); + assert!(!e.about.is_empty()); + } + } + } +} diff --git a/src/help.rs b/src/help.rs index 8b075ab..c46b031 100644 --- a/src/help.rs +++ b/src/help.rs @@ -885,8 +885,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[ name: "man", category: "Commands", usage: "man {flags} [command]", - about: "Generate roff man pages from the command definitions: one page for the whole CLI, or one page per command with --output.", - search_terms: &["documentation", "roff", "unix"], + about: "Readable man pages in the terminal (the raw roff source is kept when piped): one page for the whole CLI, or one per command with --output.", + search_terms: &["documentation", "roff", "unix", "manual"], flags: &[ HelpFlag { short: "", long: "--output", value: "DIR", desc: "Write am.1 plus one am-.1 per command into this directory" }, ], @@ -896,9 +896,10 @@ pub static HELP_SPECS: &[HelpSpec] = &[ ], io: None, examples: &[ - HelpExample { desc: "Print the man page of the whole CLI.", code: "man" }, - HelpExample { desc: "Print one command page.", code: "man install" }, + HelpExample { desc: "Read the man page of the whole CLI (rendered in the terminal).", code: "man" }, + HelpExample { desc: "Read one command page.", code: "man install" }, HelpExample { desc: "Write every page into man/ (for a package).", code: "man --output man/" }, + HelpExample { desc: "Pipe the raw roff source into a real man viewer.", code: "man | man -l -" }, ], }, HelpSpec { @@ -953,6 +954,26 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Switch to the ocean theme (kept for the next start).", code: "theme ocean" }, ], }, + HelpSpec { + name: "tip", + category: "Commands", + usage: "tip {flags}", + about: "Cheat sheet : les commandes les plus utiles avec leurs options clés, vos commandes les plus utilisées et une astuce du jour.", + search_terms: &["cheat", "sheet", "tips", "astuce", "aide", "raccourci", "usage"], + flags: &[HelpFlag { + short: "", + long: "--random", + value: "", + desc: "Show a single random tip instead of the whole page", + }], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Show the whole cheat sheet.", code: "tip" }, + HelpExample { desc: "Show a single random tip.", code: "tip --random" }, + ], + }, HelpSpec { name: "ls", category: "Shell", diff --git a/src/repl.rs b/src/repl.rs index 4af6b04..b870ae1 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -33,7 +33,7 @@ use reedline::{ Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion, }; use std::borrow::Cow; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::io::{IsTerminal, Write}; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -103,9 +103,10 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("untag", "remove a personal tag"), ("tags", "list personal tags"), ("profile", "manage environment profiles"), - ("man", "generate man pages"), + ("man", "readable man page in the terminal"), ("shell", "show or switch the system shell"), ("theme", "show or switch the color theme"), + ("tip", "cheat sheet of the most useful commands"), ("ls", "list files and directories (Nushell-style)"), ("dir", "list files and directories (same as ls)"), ("cd", "change directory — Tab completes folders"), @@ -143,6 +144,30 @@ impl AmCompleter { } completer.agents.sort(); completer.agents.dedup(); + + // Issue #41 follow-up: only propose aliases and groups whose target is + // actually launchable (installed managed or external on PATH). + let launchable: HashSet = completer.agents.iter().cloned().collect(); + completer + .aliases + .retain(|alias| match app.catalog.resolve(alias) { + Some(agent) => launchable.contains(&agent.name), + None => { + // Fallback: alias target is not a known agent name, but + // points to a launchable binary recorded in the catalog. + app.catalog + .alias_target(alias) + .map(|target| launchable.contains(target)) + .unwrap_or(false) + } + }); + completer.groups.retain(|group| { + let name = group.strip_prefix("group:").unwrap_or(group); + app.catalog + .group_members(name) + .iter() + .any(|agent| launchable.contains(&agent.name)) + }); completer } @@ -191,7 +216,7 @@ impl AmCompleter { "list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", - "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", + "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "profile", "man", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], @@ -206,7 +231,7 @@ impl AmCompleter { "--background", "--force", "--purge", "--method", "--all", "--category", "--args", "--env", "--notify", "--timeout", "--yes", "--verbose", "--quiet", "--json", "--dry-run", "--no-color", - "--profile", "--installed", "--tag", "--output", + "--profile", "--installed", "--tag", "--output", "--random", ], agents, catalog_agents, @@ -711,23 +736,35 @@ pub fn banner_box( color, ))); - // am command groups. + // am command groups: every command the REPL recognizes, grouped by + // purpose. Keep this in sync with the completer (COMMAND_DESCRIPTIONS). rows.push(blank()); 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(" catalog list · status · search · info · init".to_string())); + rows.push(inner( + " activity sessions · stats · top · report · projects · timeline · log · logs · history" + .to_string(), + )); rows.push(inner( " lifecycle install · uninstall · update · start · stop · restart · run".to_string(), )); rows.push(inner( - " system doctor · config · completion · self-update · self-uninstall · export · import" + " manage open · watch · dashboard · favorite · unfavorite · note · tag · untag · tags" .to_string(), )); rows.push(inner( - " shell ls · cd · ps · where · get · shell · theme · help · version · exit".to_string(), + " config alias · secret · profile · config · doctor · completion · man · tip".to_string(), + )); + rows.push(inner( + " system self-update · self-uninstall · export · import".to_string(), + )); + rows.push(inner( + " shell ls · dir · cd · ps · where · get · shell · theme · help · version · exit" + .to_string(), )); // Stats footer. @@ -1078,10 +1115,15 @@ fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result' may have switched the app theme: rebuild the editor - // so the completion menu follows the new palette (the history is - // file-backed and re-read from disk). - if !std::ptr::eq(theme, app.theme()) { + // Rebuild the editor when the completion pool or the palette may + // have changed: 'theme ' switches the menu colors, and the + // install/uninstall/update/alias/config/import commands mutate the + // installed agents, aliases or groups that Tab proposes (the + // completer snapshots them at startup, so a fresh install would + // stay hidden until an exit/restart otherwise). The history is + // file-backed and re-read from disk. + let first_word = trimmed.split_whitespace().next().unwrap_or(""); + if !std::ptr::eq(theme, app.theme()) || completion_pool_mutating(first_word) { theme = app.theme(); let _ = rl.sync_history(); match build_line_editor(app, &history_path) { @@ -1568,6 +1610,9 @@ fn handle_line( "import" => Command::Import { file: need("file")?.into(), }, + "tip" => Command::Tip { + random: flag("--random"), + }, other => { app.log .error(&format!("unknown command '{other}' — type 'help'")); @@ -1589,6 +1634,16 @@ fn is_shell_command(word: &str) -> bool { ) } +/// Commands whose execution can change the agent completion pool +/// (installed agents, aliases, groups): the Tab completer snapshots that +/// pool at startup, so the line editor is rebuilt after one of these runs. +fn completion_pool_mutating(first: &str) -> bool { + matches!( + first, + "install" | "uninstall" | "update" | "alias" | "config" | "import" | "self-update" + ) +} + /// Whether a word is one of the am commands (everything else is executed by /// the system shell). fn is_am_command(word: &str) -> bool { @@ -1635,6 +1690,7 @@ fn is_am_command(word: &str) -> bool { | "tags" | "profile" | "man" + | "tip" ) } @@ -1990,6 +2046,27 @@ mod tests { assert!(cands.contains(&"--dry-run".to_string())); } + #[test] + fn pool_mutating_commands_are_detected() { + // These commands change the agents/aliases/groups that Tab proposes: + // the line editor must be rebuilt after them. + for cmd in [ + "install", "uninstall", "update", "alias", "config", "import", "self-update", + ] { + assert!( + completion_pool_mutating(cmd), + "{cmd} must refresh the completion pool" + ); + } + // Read-only and system commands must not trigger a rebuild. + for cmd in ["start", "stop", "list", "status", "ls", "cd", "theme", "help", "exit"] { + assert!( + !completion_pool_mutating(cmd), + "{cmd} must not refresh the completion pool" + ); + } + } + #[test] fn completes_config_subcommands() { let c = completer(); @@ -2055,6 +2132,32 @@ mod tests { } } + #[test] + fn banner_commands_section_lists_every_command() { + let session = test_session(); + let text = banner_box( + "9.9.9", + 65, + 3, + 12, + &session, + "20260815_123456_abcdef", + "/home/alice/dev/agent-manager", + crate::theme::default_theme(), + false, + ); + // The Commands section starts at the "Commands" heading and runs to + // the stats footer. Every command the REPL knows (am + shell) must + // appear there. + let section = text.split_once("Commands").map(|(_, r)| r).unwrap_or_default(); + for (cmd, _) in COMMAND_DESCRIPTIONS { + assert!( + section.contains(&format!(" {cmd} ")), + "banner Commands section misses {cmd}" + ); + } + } + #[test] fn banner_box_is_themed_and_still_aligned() { let session = test_session(); diff --git a/src/tables.rs b/src/tables.rs index efc701b..6ad82cd 100644 --- a/src/tables.rs +++ b/src/tables.rs @@ -53,10 +53,12 @@ impl DataTable { .position(|c| c.eq_ignore_ascii_case(name)) } - /// Render the boxed Nushell-style table, fitting max_width columns. - /// When the natural layout is too wide, the first (name) column is - /// truncated with an ellipsis and the trailing columns are dropped if - /// that is still not enough — every row stays on a single line. + /// Render the boxed Nushell-style table, sized to its content and capped + /// at `max_width` so every row stays on a single line. When the row is + /// too wide, the widest column shrinks first (with an ellipsis) — identity + /// columns such as `name` stay compact while content columns such as + /// `path` keep as much room as possible. Trailing columns are dropped + /// only when even header-only columns cannot fit. pub fn render(&self, color: bool, max_width: usize) -> String { self.render_with(None, color, max_width) } @@ -97,44 +99,58 @@ impl DataTable { } cells.push(line); } - let line_width = |ncols: usize, widths: &[usize]| -> usize { - 1 + 3 * (ncols + 1) + index_width + widths.iter().sum::() - }; + // Frame overhead (borders, separators and the leading "#" index + // column) for a given number of data columns. + let fixed = |ncols: usize| 1 + 3 * (ncols + 1) + index_width; + // A column never shrinks below its header, so headers stay readable. + let min_w: Vec = headers.iter().map(|h| visible_len(h).max(1)).collect(); - // Choose the final column set and widths. + // Keep as many columns as possible: drop trailing columns only when + // even the minimal (header-only) layout cannot fit. let mut data_cols = headers.len(); - let mut widths: Vec = natural.clone(); - loop { - if data_cols == 0 { - break; - } - widths.truncate(data_cols); - if line_width(data_cols, &widths) <= max_width { - // Expand the first column into the remaining slack. - widths[0] += max_width - line_width(data_cols, &widths); - break; - } - // Cap the non-first columns, then give the first what remains. - let mut capped = widths.clone(); - for w in capped.iter_mut().skip(1) { - *w = (*w).min(16); - } - if line_width(data_cols, &capped) <= max_width { - capped[0] += max_width - line_width(data_cols, &capped); - widths = capped; - break; - } - if data_cols == 1 { - // Last resort: shrink the single column to whatever remains - // after the borders and the index column. - let fixed = 1 + 3 * (data_cols + 1) + index_width; - capped[0] = max_width.saturating_sub(fixed).max(1); - widths = capped; + while data_cols > 1 { + let min_sum: usize = min_w[..data_cols].iter().sum(); + if fixed(data_cols) + min_sum <= max_width { break; } data_cols -= 1; } + // The widest column is the "elastic" one and shrinks first when the + // row overflows. Identity columns (name, pid…) therefore stay at + // their natural width while content columns (path, description…) + // absorb the truncation. Ties go to the rightmost column. + let mut order: Vec = (0..data_cols).collect(); + order.sort_by(|&a, &b| natural[b].cmp(&natural[a]).then_with(|| b.cmp(&a))); + + let mut widths: Vec = natural[..data_cols].to_vec(); + let budget = max_width.saturating_sub(fixed(data_cols)); + let total: usize = widths.iter().sum(); + if total > budget { + // Shrink elastic columns toward their header width. + let mut needed = total - budget; + for &i in &order { + let room = widths[i].saturating_sub(min_w[i]); + let cut = room.min(needed); + widths[i] -= cut; + needed -= cut; + if needed == 0 { + break; + } + } + // Last resort: even at header width the row overflows; truncate + // the widest columns below their header (down to 1 char) so each + // row still fits on a single line. + for &i in &order { + if needed == 0 { + break; + } + let cut = widths[i].saturating_sub(1).min(needed); + widths[i] -= cut; + needed -= cut; + } + } + let all_headers: Vec = std::iter::once("#".to_string()) .chain(headers.iter().take(data_cols).cloned()) .collect(); @@ -434,19 +450,44 @@ mod tests { Cell::bytes(512, "512 B".into()), ]], }; - // Trailing columns are dropped when too narrow. + // The widest (name) column shrinks first; the trailing columns stay + // visible instead of being dropped. let text = t.render(false, 45); for line in text.lines() { assert!(line.chars().count() <= 45, "too wide: {line}"); } - assert!(!text.contains("size")); - // The name column is truncated with an ellipsis at the very end. + assert!(text.contains("size"), "size should stay visible:\n{text}"); + assert!(text.contains("file")); + // At a very narrow width the name column is truncated with an + // ellipsis, but the row still fits on a single line. let text = t.render(false, 30); for line in text.lines() { assert!(line.chars().count() <= 30, "too wide: {line}"); } - assert!(text.contains("…"), "name should be truncated: -{text}"); + assert!(text.contains("…"), "name should be truncated:\n{text}"); + assert!(text.contains("size"), "size should stay visible:\n{text}"); + } + + #[test] + fn render_shrinks_content_column_before_name() { + // Regression: the name column must not be padded at the expense of a + // long trailing column (e.g. `am list` PATH). + let t = DataTable { + columns: vec!["name".into(), "path".into()], + rows: vec![vec![ + Cell::str("claude-code"), + Cell::str("/home/u/.local/share/agents/claude-code/bin/claude.exe"), + ]], + }; + let text = t.render(false, 60); + for line in text.lines() { + assert!(line.chars().count() <= 60, "too wide: {line}"); + } + // The name stays at its natural width (no padding before the bar). + assert!(text.contains("claude-code │"), "name padded:\n{text}"); + // The path is truncated with an ellipsis, not dropped. + assert!(text.contains("…"), "path should be truncated:\n{text}"); + assert!(text.contains("/home/u/"), "path should stay visible:\n{text}"); } #[test] diff --git a/tests/completion_man_test.rs b/tests/completion_man_test.rs index b801290..036721a 100644 --- a/tests/completion_man_test.rs +++ b/tests/completion_man_test.rs @@ -86,6 +86,138 @@ fn agents_command_lists_installed_only_in_installed_mode() { assert_eq!(agents_cmd::run(&app, true).unwrap(), 0); } +#[test] +fn agents_command_filters_aliases_and_groups_by_launchable_target() { + let dir = std::env::temp_dir().join(format!("am-test-alias-group-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = common::write_config( + &dir, + r#" +agents: + - name: claude-code + display_name: "Claude Code" + category: coding-agent + install: + type: npm + package: "@anthropic-ai/claude-code" + run: claude + - name: github-copilot + display_name: "GitHub Copilot" + category: coding-agent + install: + type: npm + package: "@github/copilot" + run: copilot + - name: aider + display_name: "Aider" + category: coding-agent + install: + type: uv + package: aider-install + run: aider +aliases: + cc: claude-code + copilot: github-copilot +groups: + dev: [claude-code, aider] + empty: [github-copilot] +"#, + ); + let cli = Cli::parse_from([ + "am", + "--config", + cfg.to_str().unwrap(), + "list", + ]); + let mut app = agent_manager::app::App::from_cli(cli).expect("app should build"); + common::isolate(&mut app, &dir); + + let entry = InstalledEntry { + name: "claude-code".to_string(), + version: Some("1.0.0".to_string()), + method: "npm".to_string(), + install_dir: dir.join("claude-code").display().to_string(), + bins: vec![], + run: "claude".to_string(), + installed_at: "2026-08-01T00:00:00Z".to_string(), + updated_at: None, + pid: None, + started_at: None, + }; + app.state.set(&entry).unwrap(); + + let names = agents_cmd::agent_names(&app, true); + assert!( + names.contains(&"claude-code".to_string()), + "installed agent must appear: {names:?}" + ); + assert!( + names.contains(&"cc".to_string()), + "alias pointing to an installed agent must appear: {names:?}" + ); + assert!( + names.contains(&"group:dev".to_string()), + "group with at least one installed member must appear: {names:?}" + ); + assert!( + !names.contains(&"github-copilot".to_string()), + "agent that is not installed must not appear: {names:?}" + ); + assert!( + !names.contains(&"copilot".to_string()), + "alias pointing to a non-installed agent must not appear: {names:?}" + ); + assert!( + !names.contains(&"group:empty".to_string()), + "group with no installed members must not appear: {names:?}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rebuilt_completer_tracks_install_and_uninstall() { + // The REPL completer snapshots the agent pool at startup; the line + // editor is rebuilt after install/uninstall, and a rebuilt completer + // must reflect the state file: a fresh install shows up under Tab and + // an uninstall disappears — without restarting am. + let app = common::test_app("refresh", &["list"]); + let before = agent_manager::repl::AmCompleter::new(&app); + let (_, cands_before) = before.candidates_for("start ", 6); + assert!( + !cands_before.contains(&"pi".to_string()), + "pi must not be proposed before its install: {cands_before:?}" + ); + + app.state + .set(&InstalledEntry { + name: "pi".to_string(), + version: Some("0.84.2".to_string()), + method: "binary".to_string(), + install_dir: "/tmp/pi".to_string(), + bins: vec![], + run: "pi".to_string(), + installed_at: "2026-08-17T00:00:00Z".to_string(), + updated_at: None, + pid: None, + started_at: None, + }) + .unwrap(); + let after_install = agent_manager::repl::AmCompleter::new(&app); + let (_, cands_installed) = after_install.candidates_for("start ", 6); + assert!( + cands_installed.contains(&"pi".to_string()), + "a rebuilt completer must propose a freshly installed agent: {cands_installed:?}" + ); + + app.state.remove("pi").unwrap(); + let after_uninstall = agent_manager::repl::AmCompleter::new(&app); + let (_, cands_removed) = after_uninstall.candidates_for("start ", 6); + assert!( + !cands_removed.contains(&"pi".to_string()), + "a rebuilt completer must drop an uninstalled agent: {cands_removed:?}" + ); +} + #[test] fn man_pages_are_written_to_output_dir() { let app = common::test_app("man", &["man"]); @@ -94,7 +226,7 @@ fn man_pages_are_written_to_output_dir() { let code = man_cmd::run(&app, None, Some(&dir)).unwrap(); assert_eq!(code, 0); assert!(dir.join("am.1").exists(), "am.1 missing"); - for name in ["start", "sessions", "stats", "log", "history", "dashboard", "favorite", "profile", "man"] { + for name in ["start", "sessions", "stats", "log", "history", "dashboard", "favorite", "profile", "man", "tip"] { assert!( dir.join(format!("am-{name}.1")).exists(), "am-{name}.1 missing" @@ -109,6 +241,28 @@ fn man_pages_are_written_to_output_dir() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn man_page_renders_to_readable_text() { + // The roff source of the real CLI page must render to readable text: + // macros and font escapes gone, sections and option terms kept. + let cmd = Cli::command(); + let man = clap_mangen::Man::new(cmd); + let mut buf: Vec = Vec::new(); + man.render(&mut buf).unwrap(); + let roff = String::from_utf8_lossy(&buf); + let text = man_cmd::roff_to_text(&roff, agent_manager::theme::default_theme(), false); + assert!(text.contains("▸ NAME"), "NAME section missing:\n{text}"); + assert!(text.contains("▸ SYNOPSIS"), "SYNOPSIS section missing"); + assert!(text.contains("▸ OPTIONS"), "OPTIONS section missing"); + assert!(text.contains("▸ SUBCOMMANDS"), "SUBCOMMANDS section missing"); + assert!(!text.contains(".SH"), "macros must be removed: {text}"); + assert!(!text.contains("\\fB"), "font escapes must be converted: {text}"); + // Option terms appear before their descriptions (term/body order). + let pos_config = text.find("--config ").unwrap_or(0); + let pos_desc = text.find("Use an alternate configuration file").unwrap_or(0); + assert!(pos_config < pos_desc, "term must precede its body:\n{text}"); +} + #[test] fn unknown_man_command_is_rejected() { let app = common::test_app("manbad", &["man"]); diff --git a/tests/tip_test.rs b/tests/tip_test.rs new file mode 100644 index 0000000..44a286c --- /dev/null +++ b/tests/tip_test.rs @@ -0,0 +1,74 @@ +//! tip: the cheat sheet page (render, JSON, --random, REPL completion). + +mod common; + +use agent_manager::cli::Cli; +use agent_manager::commands::tip_cmd; +use clap::Parser; + +#[test] +fn tip_page_renders_header_sections_and_options() { + let app = common::test_app("tip", &["tip"]); + let text = tip_cmd::render(&app, false); + assert!(text.contains("TIP"), "header missing:\n{text}"); + assert!(text.contains("antisèche"), "subtitle missing:\n{text}"); + for title in [ + "Lancer & piloter", + "Installer & maintenir", + "Observer", + "Personnaliser", + "Boîte à outils", + ] { + assert!(text.contains(title), "missing section '{title}':\n{text}"); + } + assert!(text.contains("start "), "missing usage line"); + assert!(text.contains("--background"), "missing option"); + assert!(text.contains("Le saviez-vous"), "missing tip of the day"); + assert!( + text.contains("Vos commandes les plus utilisées"), + "missing personal section" + ); +} + +#[test] +fn tip_json_output_is_structured() { + let app = common::test_app("tip-json", &["tip", "--json"]); + let text = tip_cmd::render(&app, false); + let v: serde_json::Value = serde_json::from_str(&text).expect("valid JSON output"); + assert!(v["sections"].is_array(), "sections missing"); + assert_eq!(v["sections"].as_array().unwrap().len(), 5); + assert!(v["tip"]["title"].is_string()); + assert!(v["tip"]["body"].is_string()); + assert!(v["most_used"].is_array()); +} + +#[test] +fn tip_random_prints_a_single_tip() { + let app = common::test_app("tip-rnd", &["tip", "--random"]); + let text = tip_cmd::render(&app, true); + assert!(text.contains("💡"), "random tip missing: {text}"); + assert!(!text.contains("antisèche"), "random mode must not print the page"); +} + +#[test] +fn tip_run_succeeds() { + let app = common::test_app("tip-run", &["tip"]); + assert_eq!(tip_cmd::run(&app, false).unwrap(), 0); +} + +#[test] +fn cli_parses_tip_flags() { + let cli = Cli::parse_from(["am", "tip", "--random"]); + match cli.command { + Some(agent_manager::cli::Command::Tip { random }) => assert!(random, "--random must be parsed"), + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn repl_completion_proposes_tip() { + let app = common::test_app("tip-comp", &["list"]); + let c = agent_manager::repl::AmCompleter::new(&app); + let (_, cands) = c.candidates_for("ti", 2); + assert!(cands.contains(&"tip".to_string()), "candidates: {cands:?}"); +}