v0.2.14 : aide Nushell, themes de couleurs et cadres blancs

- Aide facon Nushell : help <cmd>, <cmd> -h/--help, help, help commands,
  help --find <texte>, help <agent> (fiche info) - sections Usage/Flags/
  Command Type/Parameters/Input-output/Examples avec couleurs
- Commande version : tableau Nushell (commit, build, rustc, theme...) + --json ;
  build.rs pour commit/branche git ; -V reste la version courte
- Themes : --theme <nom> (default, ocean, sunset, forest, dracula, mono)
  + settings.theme dans la config ; activation VT Windows pour les couleurs
- Cadres blancs Nushell partages : list, search, status, doctor, ls/ps/
  where/get et menu d autocompletion Tab encadre (colonnes name/description)
This commit is contained in:
2026-08-16 12:52:12 -04:00
parent 53f3fa34e7
commit 14f873bfdd
20 changed files with 2272 additions and 125 deletions
+202 -63
View File
@@ -13,14 +13,13 @@
//! available.
use crate::app::App;
use crate::cli::{Cli, Command, StartArgs};
use crate::cli::{Command, StartArgs};
use crate::commands;
use crate::nav;
use crate::ps;
use crate::tables::DataTable;
use crate::shell::ShellSession;
use anyhow::{anyhow, Result};
use clap::CommandFactory;
use colored::Colorize;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
@@ -50,6 +49,8 @@ pub struct AmCompleter {
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
/// Active color theme (used by the boxed menu highlight).
theme: &'static crate::theme::Theme,
}
/// One-line descriptions for the Tab completion menu.
@@ -86,7 +87,10 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
impl AmCompleter {
pub fn new(app: &App) -> Self {
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
let mut completer =
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups);
completer.theme = app.theme();
completer
}
pub fn from_catalog(
@@ -121,6 +125,7 @@ impl AmCompleter {
descriptions.insert(a.name.clone(), truncate_desc(&desc));
}
Self {
theme: crate::theme::default_theme(),
commands: vec![
"list", "status", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
@@ -239,25 +244,82 @@ impl Completer for AmCompleter {
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let (start, cands) = self.candidates_for(line, pos);
let pairs = cands
.into_iter()
.map(|c| {
// Nushell-style menu: show a description next to the
// candidate when one is known.
let display = match self.descriptions.get(&c) {
Some(d) if !d.is_empty() => format!("{c}\t{d}"),
_ => c.clone(),
};
Pair {
display,
let pairs = if cands.len() < 2 {
// A single match is inserted immediately (no menu).
cands
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
}
})
.collect();
})
.collect()
} else {
self.boxed_pairs(&cands)
};
Ok((start, pairs))
}
}
impl AmCompleter {
/// Build the boxed Tab menu: one candidate per box line, so rustyline's
/// single-column layout renders the whole menu as one white-framed
/// table. Decorative lines (borders, header) replace the typed word
/// with the common prefix, so completion behaves exactly as before.
fn boxed_pairs(&self, cands: &[String]) -> Vec<Pair> {
let rows: Vec<(String, String)> = cands
.iter()
.map(|c| (c.clone(), self.descriptions.get(c).cloned().unwrap_or_default()))
.collect();
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
let lines = crate::output::completion_box(&rows);
let lcp = common_prefix(cands);
let n = cands.len();
let data_start = if has_desc { 3 } else { 1 };
// Pad every line to the terminal width: rustyline then lays the
// menu out in one column, one box line per row.
let width = crate::output::terminal_width()
.unwrap_or(120)
.max(lines.iter().map(|l| l.chars().count()).max().unwrap_or(0));
lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let replacement = if i >= data_start && i < data_start + n {
cands[i - data_start].clone()
} else {
lcp.clone()
};
let display = format!(
"{line}{}",
" ".repeat(width.saturating_sub(line.chars().count()))
);
Pair {
display,
replacement,
}
})
.collect()
}
}
/// Longest common prefix of the candidates (chars, not bytes).
fn common_prefix(items: &[String]) -> String {
let mut iter = items.iter();
let Some(first) = iter.next() else {
return String::new();
};
let mut prefix = first.clone();
for s in iter {
while !s.starts_with(&prefix) && !prefix.is_empty() {
prefix.pop();
}
if prefix.is_empty() {
break;
}
}
prefix
}
impl Hinter for AmCompleter {
type Hint = String;
@@ -266,7 +328,39 @@ impl Hinter for AmCompleter {
}
}
impl Highlighter for AmCompleter {}
impl Highlighter for AmCompleter {
/// Style the boxed completion menu: borders dim, the header row in the
/// theme header style, candidate names in the accent color.
fn highlight_candidate<'c>(
&self,
candidate: &'c str,
completion: rustyline::CompletionType,
) -> std::borrow::Cow<'c, str> {
use std::borrow::Cow;
let _ = completion;
match candidate.chars().next() {
Some('╭') | Some('╰') | Some('├') => Cow::Owned(self.theme.dimmed(candidate)),
Some('│') => {
let cells: Vec<&str> = candidate.split('│').collect();
if cells.len() >= 4 {
if cells[1].trim() == "name" && cells[2].trim() == "description" {
Cow::Owned(self.theme.hdr(candidate))
} else {
Cow::Owned(format!(
"│{}│{}│{}",
self.theme.acc(cells[1]),
cells[2],
cells[3]
))
}
} else {
Cow::Borrowed(candidate)
}
}
_ => Cow::Borrowed(candidate),
}
}
}
impl Validator for AmCompleter {}
impl Helper for AmCompleter {}
@@ -682,9 +776,7 @@ pub fn run(app: &App) -> Result<i32> {
let forced = std::env::var_os("AM_FORCE_REPL").is_some();
if !std::io::stdin().is_terminal() && !forced {
// Piped stdin: print the help instead of hanging.
let mut cmd = Cli::command();
cmd.print_help()?;
println!();
crate::help::print_general(app);
return Ok(2);
}
let mut session = ShellSession::start(&app.config);
@@ -786,11 +878,11 @@ fn handle_input(
match line {
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
"help" | "?" | "/help" => {
print_help();
crate::help::print_general(app);
return Ok(false);
}
"version" | "/version" => {
println!("am {}", env!("CARGO_PKG_VERSION"));
crate::help::print_version(app);
return Ok(false);
}
_ => {}
@@ -803,6 +895,14 @@ fn handle_input(
};
match cmd {
"shell" => shell_command(session, arg),
"help" => match arg {
Some(topic) => {
if let Err(e) = crate::help::print_command(app, topic) {
app.log.error(format!("{e:#}"));
}
}
None => crate::help::print_general(app),
},
_ => app
.log
.error(&format!("unknown command '/{cmd}' — type '/help'")),
@@ -839,6 +939,35 @@ fn handle_line(
let cmd = tokens[0].as_str();
let rest = &tokens[1..];
// Nushell-style help: 'ls -h', 'ls --help', 'start -h', ... show the
// colored command help. System commands keep their own -h handling.
if is_am_command(cmd) || is_shell_command(cmd) {
let help_flag_pos = rest.iter().position(|t| t == "-h" || t == "--help");
let dd_pos = rest.iter().position(|t| t == "--");
let help_requested = match help_flag_pos {
Some(p) => dd_pos.is_none_or(|d| p < d),
None => false,
};
// 'run <agent> --help' passes --help through to the agent; only
// 'run -h' right after the command name is a help request.
let passthrough = cmd == "run" && help_flag_pos != Some(0);
if help_requested && !passthrough {
if let Err(e) = crate::help::print_command(app, cmd) {
app.log.error(format!("{e:#}"));
}
return Ok(false);
}
}
// help <topic> | help --find <text> | help commands
if cmd == "help" {
return run_help_command(app, rest);
}
if cmd == "version" {
crate::help::print_version(app);
return Ok(false);
}
// Session directory change: kept in the am process, not a child shell.
if matches!(cmd, "cd" | "chdir") {
change_dir(rest)?;
@@ -999,6 +1128,14 @@ fn handle_line(
Ok(exit_after)
}
/// Whether a word is one of the REPL's shell-level commands (ls, ps, ...).
fn is_shell_command(word: &str) -> bool {
matches!(
word,
"ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "help" | "version" | "exit"
)
}
/// Whether a word is one of the am commands (everything else is executed by
/// the system shell).
fn is_am_command(word: &str) -> bool {
@@ -1027,7 +1164,14 @@ fn is_am_command(word: &str) -> bool {
/// Resolve the path argument of ls/dir (defaults to the current directory).
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
Ok(match rest.first() {
// A leading '--' ends flag parsing, so 'ls -- -h' can list a file
// literally named "-h" instead of showing the help.
let args: Vec<&String> = if rest.first().map(String::as_str) == Some("--") {
rest.iter().skip(1).collect()
} else {
rest.iter().collect()
};
Ok(match args.first() {
Some(p) => crate::config::expand_path(p),
None => current_dir(),
})
@@ -1037,7 +1181,10 @@ fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
/// The table is rendered to fit the terminal width so rows never wrap.
fn show_table(app: &App, last: &mut Option<DataTable>, table: DataTable) {
let width = crate::output::terminal_width().unwrap_or(120);
print!("{}", table.render(app.color(), width));
print!(
"{}",
table.render_themed(app.theme(), app.color(), width)
);
*last = Some(table);
}
@@ -1057,6 +1204,18 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
}
let cmd = tokens[0].as_str();
let rest = &tokens[1..];
// Nushell-style: 'ls -h' inside a pipeline shows the command help.
if rest
.iter()
.take_while(|t| t.as_str() != "--")
.any(|t| t == "-h" || t == "--help")
&& matches!(cmd, "ls" | "dir" | "ps" | "where" | "get")
{
if let Err(e) = crate::help::print_command(app, cmd) {
app.log.error(format!("{e:#}"));
}
return Ok(false);
}
match cmd {
"ls" => {
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
@@ -1157,44 +1316,24 @@ fn print_shell_status(session: &ShellSession) {
println!("use 'shell <name>' to switch; set settings.default_shell in the config to make it permanent");
}
fn print_help() {
println!("commands:");
println!(" list [--all] list installed agents (--all: whole catalog)");
println!(" status [agent] show agent state");
println!(" search <keyword> search the catalog");
println!(" info <agent> show agent details");
println!(" install <agent> [--method <m>] install an agent (--force to reinstall)");
println!(" uninstall <agent> [--purge] remove an agent");
println!(" update <agent> | update --all update agents");
println!(" start <agent> [--background] start an agent");
println!(" stop <agent> [--force] stop an agent");
println!(" restart <agent> [--background] restart an agent");
println!(" run <agent> [args...] run an agent command directly");
println!(" doctor [--fix] check the environment");
println!(" config show | path | validate | edit");
println!(" completion <shell> generate a completion script");
println!(" self-update [--check] update agent-manager");
println!(" self-uninstall remove agent-manager and everything it created");
println!(" export [--output FILE] backup config + state");
println!(" import <file> restore a backup");
println!(" shell [list|<name>] show or switch the system shell");
println!(" ls [path] · dir [path] list files and directories (Nushell-style table)");
println!(" ps list processes");
println!(" where <col> <op> <val> filter the last table (ls | where size > 1mb)");
println!(" get <col> select a column (ls | get name)");
println!(" cd <dir> change directory — Tab completes folders");
println!(" help | version | exit this help, the version, quit");
println!();
println!("pipelines:");
println!(" ls | where size > 1mb | get name");
println!(" ps | where name =~ am");
println!();
println!("system gateway:");
println!(" any other input runs as a system command in the active shell");
println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')");
println!(" Tab inserts the common prefix and lists candidates with descriptions");
println!();
println!("slash commands: /help · /version · /exit · /shell <name>");
/// 'help' in the REPL: the overview, a command/agent topic, the command
/// list, or a --find search — the same Nushell-style help as 'am help'.
fn run_help_command(app: &App, rest: &[String]) -> Result<bool> {
let mut iter = rest.iter();
match iter.next().map(|s| s.as_str()) {
None => crate::help::print_general(app),
Some("--find") => {
let text = iter.next().map(|s| s.as_str()).unwrap_or("");
crate::help::print_find(app, text);
}
Some("-h") | Some("--help") => {
crate::help::print_command(app, "help")?;
}
Some(topic) => {
crate::help::print_command(app, topic)?;
}
}
Ok(false)
}
#[cfg(test)]