feat: am tip cheat sheet + readable man pages + REPL completion refresh
This commit is contained in:
@@ -362,6 +362,13 @@ pub enum Command {
|
|||||||
},
|
},
|
||||||
/// Display version and build information
|
/// Display version and build information
|
||||||
Version,
|
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
|
/// Manage the configuration file
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Config(ConfigCmd),
|
Config(ConfigCmd),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! launched: installed agents (managed + external), aliases and groups.
|
//! launched: installed agents (managed + external), aliases and groups.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// The completion pool: installed agents (managed + external), aliases and
|
/// The completion pool: installed agents (managed + external), aliases and
|
||||||
/// groups when installed is true, the whole catalog otherwise.
|
/// 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();
|
let mut names: Vec<String> = Vec::new();
|
||||||
if installed {
|
if installed {
|
||||||
let probe = crate::probe::detect_externals(app);
|
let probe = crate::probe::detect_externals(app);
|
||||||
|
let mut launchable: HashSet<String> = HashSet::new();
|
||||||
for a in app.catalog.agents() {
|
for a in app.catalog.agents() {
|
||||||
if app.state.get(&a.name).ok().flatten().is_some()
|
if app.state.get(&a.name).ok().flatten().is_some()
|
||||||
|| probe.path_of(&a.name).is_some()
|
|| probe.path_of(&a.name).is_some()
|
||||||
{
|
{
|
||||||
|
launchable.insert(a.name.clone());
|
||||||
names.push(a.name.clone());
|
names.push(a.name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
names.extend(app.config.aliases.keys().cloned());
|
for (alias, target) in &app.config.aliases {
|
||||||
for g in app.config.groups.keys() {
|
let is_launchable = app
|
||||||
names.push(format!("group:{g}"));
|
.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 {
|
} else {
|
||||||
names.extend(app.catalog.agents().iter().map(|a| a.name.clone()));
|
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).
|
//! 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
|
//! '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.
|
//! 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 super::*;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use clap::CommandFactory;
|
use clap::CommandFactory;
|
||||||
use std::io::Write;
|
use std::io::{IsTerminal, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i32> {
|
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()
|
dir.join(format!("am-{name}.1")).display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
None => man.render(&mut std::io::stdout())?,
|
None => print_page(app, &man)?,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => match output {
|
None => match output {
|
||||||
@@ -51,7 +55,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
|
|||||||
cmd.get_subcommands().count()
|
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)
|
Ok(0)
|
||||||
@@ -68,3 +72,334 @@ fn write_page(dir: &Path, name: &str, man: &clap_mangen::Man) -> Result<()> {
|
|||||||
file.flush().ok();
|
file.flush().ok();
|
||||||
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 self_update;
|
||||||
pub mod status_cmd;
|
pub mod status_cmd;
|
||||||
pub mod timeline_cmd;
|
pub mod timeline_cmd;
|
||||||
|
pub mod tip_cmd;
|
||||||
pub mod uninstall_cmd;
|
pub mod uninstall_cmd;
|
||||||
pub mod update_cmd;
|
pub mod update_cmd;
|
||||||
pub mod watch_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::Init { force } => init_cmd::run(app, *force),
|
||||||
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
|
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
|
||||||
Command::Version => version_cmd::run(app),
|
Command::Version => version_cmd::run(app),
|
||||||
|
Command::Tip { random } => tip_cmd::run(app, *random),
|
||||||
Command::Config(sub) => config_cmd::run(app, sub),
|
Command::Config(sub) => config_cmd::run(app, sub),
|
||||||
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
|
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
|
||||||
Command::Run { agent, args } => run_cmd::run(app, agent, args),
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-4
@@ -885,8 +885,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
name: "man",
|
name: "man",
|
||||||
category: "Commands",
|
category: "Commands",
|
||||||
usage: "man {flags} [command]",
|
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.",
|
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"],
|
search_terms: &["documentation", "roff", "unix", "manual"],
|
||||||
flags: &[
|
flags: &[
|
||||||
HelpFlag { short: "", long: "--output", value: "DIR", desc: "Write am.1 plus one am-<command>.1 per command into this directory" },
|
HelpFlag { short: "", long: "--output", value: "DIR", desc: "Write am.1 plus one am-<command>.1 per command into this directory" },
|
||||||
],
|
],
|
||||||
@@ -896,9 +896,10 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
],
|
],
|
||||||
io: None,
|
io: None,
|
||||||
examples: &[
|
examples: &[
|
||||||
HelpExample { desc: "Print the man page of the whole CLI.", code: "man" },
|
HelpExample { desc: "Read the man page of the whole CLI (rendered in the terminal).", code: "man" },
|
||||||
HelpExample { desc: "Print one command page.", code: "man install" },
|
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: "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 {
|
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" },
|
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 {
|
HelpSpec {
|
||||||
name: "ls",
|
name: "ls",
|
||||||
category: "Shell",
|
category: "Shell",
|
||||||
|
|||||||
+115
-12
@@ -33,7 +33,7 @@ use reedline::{
|
|||||||
Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
|
Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
|
||||||
};
|
};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::io::{IsTerminal, Write};
|
use std::io::{IsTerminal, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -103,9 +103,10 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
|||||||
("untag", "remove a personal tag"),
|
("untag", "remove a personal tag"),
|
||||||
("tags", "list personal tags"),
|
("tags", "list personal tags"),
|
||||||
("profile", "manage environment profiles"),
|
("profile", "manage environment profiles"),
|
||||||
("man", "generate man pages"),
|
("man", "readable man page in the terminal"),
|
||||||
("shell", "show or switch the system shell"),
|
("shell", "show or switch the system shell"),
|
||||||
("theme", "show or switch the color theme"),
|
("theme", "show or switch the color theme"),
|
||||||
|
("tip", "cheat sheet of the most useful commands"),
|
||||||
("ls", "list files and directories (Nushell-style)"),
|
("ls", "list files and directories (Nushell-style)"),
|
||||||
("dir", "list files and directories (same as ls)"),
|
("dir", "list files and directories (same as ls)"),
|
||||||
("cd", "change directory — Tab completes folders"),
|
("cd", "change directory — Tab completes folders"),
|
||||||
@@ -143,6 +144,30 @@ impl AmCompleter {
|
|||||||
}
|
}
|
||||||
completer.agents.sort();
|
completer.agents.sort();
|
||||||
completer.agents.dedup();
|
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<String> = 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
|
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",
|
"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",
|
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||||
"self-update", "self-uninstall", "export", "import", "shell", "theme",
|
"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",
|
"profile", "man",
|
||||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||||
],
|
],
|
||||||
@@ -206,7 +231,7 @@ impl AmCompleter {
|
|||||||
"--background", "--force", "--purge", "--method", "--all",
|
"--background", "--force", "--purge", "--method", "--all",
|
||||||
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
|
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
|
||||||
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
|
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
|
||||||
"--profile", "--installed", "--tag", "--output",
|
"--profile", "--installed", "--tag", "--output", "--random",
|
||||||
],
|
],
|
||||||
agents,
|
agents,
|
||||||
catalog_agents,
|
catalog_agents,
|
||||||
@@ -711,23 +736,35 @@ pub fn banner_box(
|
|||||||
color,
|
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(blank());
|
||||||
rows.push(inner(if color {
|
rows.push(inner(if color {
|
||||||
theme.hdr("Commands")
|
theme.hdr("Commands")
|
||||||
} else {
|
} else {
|
||||||
"Commands".to_string()
|
"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(
|
rows.push(inner(
|
||||||
" lifecycle install · uninstall · update · start · stop · restart · run".to_string(),
|
" lifecycle install · uninstall · update · start · stop · restart · run".to_string(),
|
||||||
));
|
));
|
||||||
rows.push(inner(
|
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(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
rows.push(inner(
|
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.
|
// Stats footer.
|
||||||
@@ -1078,10 +1115,15 @@ fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i
|
|||||||
let result = handle_input(app, session, &mut last, &trimmed);
|
let result = handle_input(app, session, &mut last, &trimmed);
|
||||||
last_duration = Some(t.elapsed());
|
last_duration = Some(t.elapsed());
|
||||||
record_history(app, sid, &trimmed, &result, t.elapsed());
|
record_history(app, sid, &trimmed, &result, t.elapsed());
|
||||||
// 'theme <name>' may have switched the app theme: rebuild the editor
|
// Rebuild the editor when the completion pool or the palette may
|
||||||
// so the completion menu follows the new palette (the history is
|
// have changed: 'theme <name>' switches the menu colors, and the
|
||||||
// file-backed and re-read from disk).
|
// install/uninstall/update/alias/config/import commands mutate the
|
||||||
if !std::ptr::eq(theme, app.theme()) {
|
// 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();
|
theme = app.theme();
|
||||||
let _ = rl.sync_history();
|
let _ = rl.sync_history();
|
||||||
match build_line_editor(app, &history_path) {
|
match build_line_editor(app, &history_path) {
|
||||||
@@ -1568,6 +1610,9 @@ fn handle_line(
|
|||||||
"import" => Command::Import {
|
"import" => Command::Import {
|
||||||
file: need("file")?.into(),
|
file: need("file")?.into(),
|
||||||
},
|
},
|
||||||
|
"tip" => Command::Tip {
|
||||||
|
random: flag("--random"),
|
||||||
|
},
|
||||||
other => {
|
other => {
|
||||||
app.log
|
app.log
|
||||||
.error(&format!("unknown command '{other}' — type 'help'"));
|
.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
|
/// Whether a word is one of the am commands (everything else is executed by
|
||||||
/// the system shell).
|
/// the system shell).
|
||||||
fn is_am_command(word: &str) -> bool {
|
fn is_am_command(word: &str) -> bool {
|
||||||
@@ -1635,6 +1690,7 @@ fn is_am_command(word: &str) -> bool {
|
|||||||
| "tags"
|
| "tags"
|
||||||
| "profile"
|
| "profile"
|
||||||
| "man"
|
| "man"
|
||||||
|
| "tip"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1990,6 +2046,27 @@ mod tests {
|
|||||||
assert!(cands.contains(&"--dry-run".to_string()));
|
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]
|
#[test]
|
||||||
fn completes_config_subcommands() {
|
fn completes_config_subcommands() {
|
||||||
let c = completer();
|
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]
|
#[test]
|
||||||
fn banner_box_is_themed_and_still_aligned() {
|
fn banner_box_is_themed_and_still_aligned() {
|
||||||
let session = test_session();
|
let session = test_session();
|
||||||
|
|||||||
+81
-40
@@ -53,10 +53,12 @@ impl DataTable {
|
|||||||
.position(|c| c.eq_ignore_ascii_case(name))
|
.position(|c| c.eq_ignore_ascii_case(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the boxed Nushell-style table, fitting max_width columns.
|
/// Render the boxed Nushell-style table, sized to its content and capped
|
||||||
/// When the natural layout is too wide, the first (name) column is
|
/// at `max_width` so every row stays on a single line. When the row is
|
||||||
/// truncated with an ellipsis and the trailing columns are dropped if
|
/// too wide, the widest column shrinks first (with an ellipsis) — identity
|
||||||
/// that is still not enough — every row stays on a single line.
|
/// 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 {
|
pub fn render(&self, color: bool, max_width: usize) -> String {
|
||||||
self.render_with(None, color, max_width)
|
self.render_with(None, color, max_width)
|
||||||
}
|
}
|
||||||
@@ -97,44 +99,58 @@ impl DataTable {
|
|||||||
}
|
}
|
||||||
cells.push(line);
|
cells.push(line);
|
||||||
}
|
}
|
||||||
let line_width = |ncols: usize, widths: &[usize]| -> usize {
|
// Frame overhead (borders, separators and the leading "#" index
|
||||||
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
|
// 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<usize> = 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 data_cols = headers.len();
|
||||||
let mut widths: Vec<usize> = natural.clone();
|
while data_cols > 1 {
|
||||||
loop {
|
let min_sum: usize = min_w[..data_cols].iter().sum();
|
||||||
if data_cols == 0 {
|
if fixed(data_cols) + min_sum <= max_width {
|
||||||
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;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
data_cols -= 1;
|
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<usize> = (0..data_cols).collect();
|
||||||
|
order.sort_by(|&a, &b| natural[b].cmp(&natural[a]).then_with(|| b.cmp(&a)));
|
||||||
|
|
||||||
|
let mut widths: Vec<usize> = 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<String> = std::iter::once("#".to_string())
|
let all_headers: Vec<String> = std::iter::once("#".to_string())
|
||||||
.chain(headers.iter().take(data_cols).cloned())
|
.chain(headers.iter().take(data_cols).cloned())
|
||||||
.collect();
|
.collect();
|
||||||
@@ -434,19 +450,44 @@ mod tests {
|
|||||||
Cell::bytes(512, "512 B".into()),
|
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);
|
let text = t.render(false, 45);
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
assert!(line.chars().count() <= 45, "too wide: {line}");
|
assert!(line.chars().count() <= 45, "too wide: {line}");
|
||||||
}
|
}
|
||||||
assert!(!text.contains("size"));
|
assert!(text.contains("size"), "size should stay visible:\n{text}");
|
||||||
// The name column is truncated with an ellipsis at the very end.
|
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);
|
let text = t.render(false, 30);
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
assert!(line.chars().count() <= 30, "too wide: {line}");
|
assert!(line.chars().count() <= 30, "too wide: {line}");
|
||||||
}
|
}
|
||||||
assert!(text.contains("…"), "name should be truncated:
|
assert!(text.contains("…"), "name should be truncated:\n{text}");
|
||||||
{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]
|
#[test]
|
||||||
|
|||||||
@@ -86,6 +86,138 @@ fn agents_command_lists_installed_only_in_installed_mode() {
|
|||||||
assert_eq!(agents_cmd::run(&app, true).unwrap(), 0);
|
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]
|
#[test]
|
||||||
fn man_pages_are_written_to_output_dir() {
|
fn man_pages_are_written_to_output_dir() {
|
||||||
let app = common::test_app("man", &["man"]);
|
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();
|
let code = man_cmd::run(&app, None, Some(&dir)).unwrap();
|
||||||
assert_eq!(code, 0);
|
assert_eq!(code, 0);
|
||||||
assert!(dir.join("am.1").exists(), "am.1 missing");
|
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!(
|
assert!(
|
||||||
dir.join(format!("am-{name}.1")).exists(),
|
dir.join(format!("am-{name}.1")).exists(),
|
||||||
"am-{name}.1 missing"
|
"am-{name}.1 missing"
|
||||||
@@ -109,6 +241,28 @@ fn man_pages_are_written_to_output_dir() {
|
|||||||
let _ = std::fs::remove_dir_all(&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<u8> = 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 <FILE>").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]
|
#[test]
|
||||||
fn unknown_man_command_is_rejected() {
|
fn unknown_man_command_is_rejected() {
|
||||||
let app = common::test_app("manbad", &["man"]);
|
let app = common::test_app("manbad", &["man"]);
|
||||||
|
|||||||
@@ -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 <agent>"), "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:?}");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user