feat: am tip cheat sheet + readable man pages + REPL completion refresh

This commit is contained in:
2026-08-17 21:50:48 -04:00
parent 53893f2967
commit 98c0b30f68
10 changed files with 1402 additions and 63 deletions
+115 -12
View File
@@ -33,7 +33,7 @@ use reedline::{
Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};
@@ -103,9 +103,10 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("untag", "remove a personal tag"),
("tags", "list personal tags"),
("profile", "manage environment profiles"),
("man", "generate man pages"),
("man", "readable man page in the terminal"),
("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"),
("tip", "cheat sheet of the most useful commands"),
("ls", "list files and directories (Nushell-style)"),
("dir", "list files and directories (same as ls)"),
("cd", "change directory — Tab completes folders"),
@@ -143,6 +144,30 @@ impl AmCompleter {
}
completer.agents.sort();
completer.agents.dedup();
// Issue #41 follow-up: only propose aliases and groups whose target is
// actually launchable (installed managed or external on PATH).
let launchable: HashSet<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
}
@@ -191,7 +216,7 @@ impl AmCompleter {
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme",
"dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
@@ -206,7 +231,7 @@ impl AmCompleter {
"--background", "--force", "--purge", "--method", "--all",
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
"--profile", "--installed", "--tag", "--output",
"--profile", "--installed", "--tag", "--output", "--random",
],
agents,
catalog_agents,
@@ -711,23 +736,35 @@ pub fn banner_box(
color,
)));
// am command groups.
// am command groups: every command the REPL recognizes, grouped by
// purpose. Keep this in sync with the completer (COMMAND_DESCRIPTIONS).
rows.push(blank());
rows.push(inner(if color {
theme.hdr("Commands")
} else {
"Commands".to_string()
}));
rows.push(inner(" catalog list · status · search · info".to_string()));
rows.push(inner(" catalog list · status · search · info · init".to_string()));
rows.push(inner(
" activity sessions · stats · top · report · projects · timeline · log · logs · history"
.to_string(),
));
rows.push(inner(
" lifecycle install · uninstall · update · start · stop · restart · run".to_string(),
));
rows.push(inner(
" system doctor · config · completion · self-update · self-uninstall · export · import"
" manage open · watch · dashboard · favorite · unfavorite · note · tag · untag · tags"
.to_string(),
));
rows.push(inner(
" shell ls · cd · ps · where · get · shell · theme · help · version · exit".to_string(),
" config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
));
rows.push(inner(
" system self-update · self-uninstall · export · import".to_string(),
));
rows.push(inner(
" shell ls · dir · cd · ps · where · get · shell · theme · help · version · exit"
.to_string(),
));
// Stats footer.
@@ -1078,10 +1115,15 @@ fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i
let result = handle_input(app, session, &mut last, &trimmed);
last_duration = Some(t.elapsed());
record_history(app, sid, &trimmed, &result, t.elapsed());
// 'theme <name>' may have switched the app theme: rebuild the editor
// so the completion menu follows the new palette (the history is
// file-backed and re-read from disk).
if !std::ptr::eq(theme, app.theme()) {
// Rebuild the editor when the completion pool or the palette may
// have changed: 'theme <name>' switches the menu colors, and the
// install/uninstall/update/alias/config/import commands mutate the
// installed agents, aliases or groups that Tab proposes (the
// completer snapshots them at startup, so a fresh install would
// stay hidden until an exit/restart otherwise). The history is
// file-backed and re-read from disk.
let first_word = trimmed.split_whitespace().next().unwrap_or("");
if !std::ptr::eq(theme, app.theme()) || completion_pool_mutating(first_word) {
theme = app.theme();
let _ = rl.sync_history();
match build_line_editor(app, &history_path) {
@@ -1568,6 +1610,9 @@ fn handle_line(
"import" => Command::Import {
file: need("file")?.into(),
},
"tip" => Command::Tip {
random: flag("--random"),
},
other => {
app.log
.error(&format!("unknown command '{other}' — type 'help'"));
@@ -1589,6 +1634,16 @@ fn is_shell_command(word: &str) -> bool {
)
}
/// Commands whose execution can change the agent completion pool
/// (installed agents, aliases, groups): the Tab completer snapshots that
/// pool at startup, so the line editor is rebuilt after one of these runs.
fn completion_pool_mutating(first: &str) -> bool {
matches!(
first,
"install" | "uninstall" | "update" | "alias" | "config" | "import" | "self-update"
)
}
/// Whether a word is one of the am commands (everything else is executed by
/// the system shell).
fn is_am_command(word: &str) -> bool {
@@ -1635,6 +1690,7 @@ fn is_am_command(word: &str) -> bool {
| "tags"
| "profile"
| "man"
| "tip"
)
}
@@ -1990,6 +2046,27 @@ mod tests {
assert!(cands.contains(&"--dry-run".to_string()));
}
#[test]
fn pool_mutating_commands_are_detected() {
// These commands change the agents/aliases/groups that Tab proposes:
// the line editor must be rebuilt after them.
for cmd in [
"install", "uninstall", "update", "alias", "config", "import", "self-update",
] {
assert!(
completion_pool_mutating(cmd),
"{cmd} must refresh the completion pool"
);
}
// Read-only and system commands must not trigger a rebuild.
for cmd in ["start", "stop", "list", "status", "ls", "cd", "theme", "help", "exit"] {
assert!(
!completion_pool_mutating(cmd),
"{cmd} must not refresh the completion pool"
);
}
}
#[test]
fn completes_config_subcommands() {
let c = completer();
@@ -2055,6 +2132,32 @@ mod tests {
}
}
#[test]
fn banner_commands_section_lists_every_command() {
let session = test_session();
let text = banner_box(
"9.9.9",
65,
3,
12,
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
crate::theme::default_theme(),
false,
);
// The Commands section starts at the "Commands" heading and runs to
// the stats footer. Every command the REPL knows (am + shell) must
// appear there.
let section = text.split_once("Commands").map(|(_, r)| r).unwrap_or_default();
for (cmd, _) in COMMAND_DESCRIPTIONS {
assert!(
section.contains(&format!(" {cmd} ")),
"banner Commands section misses {cmd}"
);
}
}
#[test]
fn banner_box_is_themed_and_still_aligned() {
let session = test_session();