feat: am tip cheat sheet + readable man pages + REPL completion refresh
This commit is contained in:
@@ -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<String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
if installed {
|
||||
let probe = crate::probe::detect_externals(app);
|
||||
let mut launchable: HashSet<String> = 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()));
|
||||
|
||||
+338
-3
@@ -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 <command>' prints one
|
||||
//! page; '--output DIR' writes am.1 plus one am-<command>.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<i32> {
|
||||
@@ -30,7 +34,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
|
||||
dir.join(format!("am-{name}.1")).display()
|
||||
));
|
||||
}
|
||||
None => 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<i3
|
||||
cmd.get_subcommands().count()
|
||||
));
|
||||
}
|
||||
None => 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<u8> = 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<String> = 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::<Vec<_>>().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<char> = 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<FILE>\\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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<i32> {
|
||||
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),
|
||||
|
||||
@@ -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 <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: "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")],
|
||||
example: "top --period 30d",
|
||||
},
|
||||
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: "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(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 <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} {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<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!(
|
||||
" {} — 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:<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user