Corrige au passage deux bugs du keyring (#36) : features natifs manquants du crate keyring (set() no-op silencieux, rien ne persistait) + index __index__ jamais purgé au remove (list fantômes).
749 lines
29 KiB
Rust
749 lines
29 KiB
Rust
//! 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 <agent>",
|
||
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 <nom>", "profil d'environnement (env + args)"),
|
||
],
|
||
example: "start claude-code --background",
|
||
},
|
||
TipEntry {
|
||
usage: "stop <agent>",
|
||
about: "arrête un agent en arrière-plan (SIGTERM puis SIGKILL)",
|
||
options: &[
|
||
("--force", "kill immédiat, sans délai de grâce"),
|
||
("--timeout <secs>", "délai de grâce avant le kill (défaut : 5 s)"),
|
||
],
|
||
example: "stop pi",
|
||
},
|
||
TipEntry {
|
||
usage: "restart <agent>",
|
||
about: "redémarre avec les mêmes options (agent, groupe, profil…)",
|
||
options: &[],
|
||
example: "restart group:dev",
|
||
},
|
||
TipEntry {
|
||
usage: "watch <agent>",
|
||
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 <agent>",
|
||
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 <key>", "name, version ou status"),
|
||
("--category <cat>", "filtre par catégorie"),
|
||
],
|
||
example: "list --all",
|
||
},
|
||
TipEntry {
|
||
usage: "search <mot>",
|
||
about: "cherche dans le catalogue (nom, description, catégorie, tags)",
|
||
options: &[],
|
||
example: "search pi",
|
||
},
|
||
TipEntry {
|
||
usage: "suggest <requête>",
|
||
about: "recommande un agent pour une demande en langage naturel (tags + usage réel)",
|
||
options: &[("--json", "sortie machine-readable")],
|
||
example: "suggest un agent pour du Python",
|
||
},
|
||
TipEntry {
|
||
usage: "models",
|
||
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
|
||
options: &[
|
||
("--prune", "purge les modèles inutilisés"),
|
||
("--dry-run", "simulation sans rien supprimer"),
|
||
("--days <n>", "seuil d'inutilisation (défaut : 30)"),
|
||
],
|
||
example: "models --prune --dry-run",
|
||
},
|
||
TipEntry {
|
||
usage: "catalog update",
|
||
about: "rafraîchit le catalogue officiel (diff + confirmation)",
|
||
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
|
||
example: "catalog add https://git.dracodev.net/team/agents.yaml",
|
||
},
|
||
TipEntry {
|
||
usage: "audit",
|
||
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
|
||
options: &[("--json", "rapport machine-readable")],
|
||
example: "audit --json",
|
||
},
|
||
TipEntry {
|
||
usage: "service install <agent> --autostart",
|
||
about: "agent en service système (systemd / launchd / tâche Windows) avec démarrage auto",
|
||
options: &[
|
||
("status <agent>", "état du service"),
|
||
("uninstall <agent>", "retire le service"),
|
||
],
|
||
example: "service install claude-code --autostart",
|
||
},
|
||
TipEntry {
|
||
usage: "schedule add <cmd...> --at HH:MM",
|
||
about: "planifie une commande am quotidienne (ex: update --all le matin)",
|
||
options: &[
|
||
("schedule list", "liste les planifications"),
|
||
("schedule remove <id>", "supprime une planification"),
|
||
],
|
||
example: "schedule add update --all --at 06:30",
|
||
},
|
||
TipEntry {
|
||
usage: "doctor --watch <secondes>",
|
||
about: "vérifications de l'environnement en continu, alerte dès qu'un problème apparaît",
|
||
options: &[("--fix", "répare automatiquement ce qui peut l'être")],
|
||
example: "doctor --watch 60",
|
||
},
|
||
TipEntry {
|
||
usage: "start group:dev --parallel",
|
||
about: "démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)",
|
||
options: &[("run <agent> --container", "exécute l'agent dans son profil conteneur (docker/podman)")],
|
||
example: "start group:dev --parallel",
|
||
},
|
||
TipEntry {
|
||
usage: "update --rollback",
|
||
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
|
||
options: &[
|
||
("--rollback list", "liste les points de restauration"),
|
||
("--rollback <id>", "restaure un point précis"),
|
||
],
|
||
example: "update --rollback list",
|
||
},
|
||
TipEntry {
|
||
usage: "init --template <stack>",
|
||
about: "génère une config projet pré-remplie (web, python, rust, cli)",
|
||
options: &[("--template list", "liste les templates disponibles")],
|
||
example: "init --template python",
|
||
},
|
||
TipEntry {
|
||
usage: "sessions --export <id>",
|
||
about: "exporte une session : métadonnées + commandes + extrait de log (JSON)",
|
||
options: &[
|
||
("--output <fichier>", "destination (défaut ./am-session-<id>.json)"),
|
||
("--retention <jours>", "purge les sessions terminées au-delà de N jours"),
|
||
],
|
||
example: "sessions --export 20260815_143926 --output session.json",
|
||
},
|
||
TipEntry {
|
||
usage: "install <agent>",
|
||
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
|
||
options: &[
|
||
("--method <m>", "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 <agent>",
|
||
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 <agent>",
|
||
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 <agent>",
|
||
about: "lit la fin du journal de l'agent",
|
||
options: &[
|
||
("--follow", "suit le log en direct"),
|
||
("--lines <N>", "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 <p>", "7d, 30d, 90d ou all"), ("stats --costs", "tokens + coût estimé par agent (issue #49)")],
|
||
example: "stats --period 30d --costs",
|
||
},
|
||
TipEntry {
|
||
usage: "monitor",
|
||
about: "TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils",
|
||
options: &[
|
||
("--interval <s>", "rafraîchissement (défaut 2s)"),
|
||
("--json", "flux JSON pour les scripts"),
|
||
],
|
||
example: "monitor --interval 5",
|
||
},
|
||
TipEntry {
|
||
usage: "web",
|
||
about: "dashboard web local en lecture seule : vue d'ensemble, graphiques, sessions, événements, projets (issue #54)",
|
||
options: &[
|
||
("--port <p>", "port d'écoute (défaut 7878)"),
|
||
("--no-open", "ne pas ouvrir le navigateur automatiquement"),
|
||
],
|
||
example: "web --port 9090",
|
||
},
|
||
TipEntry {
|
||
usage: "sync",
|
||
about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)",
|
||
options: &[("--message <m>", "message de commit personnalisé"), ("sync_on_exit: true", "pousse automatiquement à la fermeture du REPL")],
|
||
example: "sync --message \"sauvegarde du soir\"",
|
||
},
|
||
TipEntry {
|
||
usage: "migrate",
|
||
about: "export/import d'un bundle de transfert machine A → B (config + état + historique + backups)",
|
||
options: &[("--export", "crée agent-manager-migrate.amx"), ("--output <f>", "chemin du bundle")],
|
||
example: "migrate --export --output backup.amx",
|
||
},
|
||
TipEntry {
|
||
usage: "history 12..25 --save deploy.yaml",
|
||
about: "exporte une plage d'historique en playbook YAML (état_dir/playbooks/)",
|
||
options: &[("--save <f>", "fichier playbook"), ("playbook <f>", "rejoue pas à pas avec confirmation")],
|
||
example: "history 12..25 --save deploy.yaml && playbook deploy.yaml",
|
||
},
|
||
TipEntry {
|
||
usage: "lab --agents claude-code,aider --task hello.yaml",
|
||
about: "benchmark : même tâche sur plusieurs agents (durée, exit, coût)",
|
||
options: &[("--parallel", "exécution concurrente"), ("--json", "rapport comparatif stable (scripts/CI)")],
|
||
example: "lab --agents claude-code,aider --task bench/hello.yaml --json",
|
||
},
|
||
TipEntry {
|
||
usage: "plugins --test notify",
|
||
about: "scripts d'extension sur les événements (contrat JSON stdin/stdout, timeout)",
|
||
options: &[("--test <name>", "exécute un plugin sur un événement de test (CI)")],
|
||
example: "plugins && plugins --test notify",
|
||
},
|
||
TipEntry {
|
||
usage: "timeline",
|
||
about: "une vue chronologique de toute l'activité",
|
||
options: &[("--since <date>", "activité à partir d'une date")],
|
||
example: "timeline --since 2026-08-01",
|
||
},
|
||
],
|
||
},
|
||
TipSection {
|
||
icon: "⚙️",
|
||
title: "Personnaliser",
|
||
entries: &[
|
||
TipEntry {
|
||
usage: "alias add <nom> <agent>",
|
||
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 <nom>",
|
||
about: "change la palette du shell, des tableaux et de cette page",
|
||
options: &[],
|
||
example: "theme ocean",
|
||
},
|
||
TipEntry {
|
||
usage: "secret set <nom> --agent <a> --value <v>",
|
||
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: "providers add <nom> --base-url <url> · set-token <nom> --value <v>",
|
||
about: "registre des providers LLM (modèles, défaut) + token partagé dans le keyring",
|
||
options: &[],
|
||
example: "providers set-token deepseek --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 <file>", "fichier de destination (défaut : am-export.json)")],
|
||
example: "export --output backup.json",
|
||
},
|
||
TipEntry {
|
||
usage: "completion <shell> --installed",
|
||
about: "complétion dynamique : agents installés, alias et groupes au Tab",
|
||
options: &[],
|
||
example: "completion powershell --installed",
|
||
},
|
||
TipEntry {
|
||
usage: "man <commande>",
|
||
about: "page de manuel pour une commande",
|
||
options: &[],
|
||
example: "man start",
|
||
},
|
||
TipEntry {
|
||
usage: "help --find <mot>",
|
||
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 <mot>' 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<i32> {
|
||
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(crate::i18n::tr_in(app.lang(), title)),
|
||
crate::i18n::tr_in(app.lang(), 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, app.lang(), 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(crate::i18n::tr_in(app.lang(), title)),
|
||
crate::i18n::tr_in(app.lang(), body)
|
||
));
|
||
out.push('\n');
|
||
out.push_str(&format!(
|
||
" {}\n",
|
||
p.dim(crate::i18n::tr_in(
|
||
app.lang(),
|
||
"am help <commande> 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} {} ", crate::i18n::tr_in(app.lang(), 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, lang: crate::i18n::Lang, e: &TipEntry) -> String {
|
||
let mut out = String::new();
|
||
out.push_str(&format!(
|
||
" {} {} {}\n",
|
||
p.inf("▸"),
|
||
p.acc(e.usage),
|
||
crate::i18n::tr_in(lang, e.about)
|
||
));
|
||
for (flag, desc) in e.options {
|
||
out.push_str(&format!(
|
||
" {} {}\n",
|
||
p.dim(flag),
|
||
crate::i18n::tr_in(lang, 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<String, usize> = 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!(
|
||
" {} — {}\n",
|
||
p.dim(crate::i18n::tr_in(app.lang(), "aucune commande enregistrée")),
|
||
crate::i18n::tr_in(
|
||
app.lang(),
|
||
"lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici"
|
||
)
|
||
));
|
||
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:<name_w$}")),
|
||
p.dim(&usage_bar(*count, max)),
|
||
p.inf(&count.to_string()),
|
||
));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Tip of the day: deterministic pick that changes daily.
|
||
fn daily_tip() -> (&'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<serde_json::Value> = 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::<Vec<_>>(),
|
||
"example": e.example,
|
||
})).collect::<Vec<_>>(),
|
||
})
|
||
})
|
||
.collect();
|
||
let payload = json!({
|
||
"sections": sections,
|
||
"most_used": most_used(app).iter().map(|(command, count)| json!({ "command": command, "count": count })).collect::<Vec<_>>(),
|
||
"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());
|
||
}
|
||
}
|
||
}
|
||
}
|