v0.2.8 : navigation facon Nushell — commande ls en tableau encadre (type, taille, date), completion Tab des dossiers pour cd (niveau par niveau) et des chemins pour ls, descriptions dans le menu Tab

This commit is contained in:
2026-08-15 23:42:32 -04:00
parent d886de322c
commit 3e1b66809a
6 changed files with 499 additions and 12 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.2.7" version = "0.2.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.2.7" version = "0.2.8"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" license = "MIT"
+8 -2
View File
@@ -181,13 +181,19 @@ démarrage et mis en évidence** dans la bannière (config
`settings.default_shell`, puis `SHELL`/`COMSPEC`, puis détection sur le `settings.default_shell`, puis `SHELL`/`COMSPEC`, puis détection sur le
PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish). PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
❯ ls # exécuté par le shell actif (cmd, pwsh, bash, ...) ❯ ls # liste façon Nushell : tableau encadré (type, taille, date)
❯ ls src # un chemin précis — Tab complète fichiers et dossiers
❯ cd <Tab> # complétion des dossiers uniquement, un niveau par Tab
❯ shell # affiche le shell courant + les shells disponibles ❯ shell # affiche le shell courant + les shells disponibles
❯ shell bash # change le shell de la session ❯ shell bash # change le shell de la session
❯ !list # force l'exécution système (même nom qu'une commande am) ❯ !ls · !list # force l'exécution système (même nom qu'une commande am)
❯ cd ~/projets # change le répertoire de la session (persistant) ❯ cd ~/projets # change le répertoire de la session (persistant)
❯ /help # commandes slash : /help · /version · /exit · /shell <nom> ❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
Le menu Tab affiche une **description** à côté de chaque candidat
(commandes et agents), comme Nushell, et **cycle** entre les propositions à
chaque Tab.
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
`config.yaml` (voir `am config path`). `config.yaml` (voir `am config path`).
+1
View File
@@ -20,6 +20,7 @@ pub mod deps;
pub mod doctor; pub mod doctor;
pub mod download; pub mod download;
pub mod installers; pub mod installers;
pub mod nav;
pub mod output; pub mod output;
pub mod probe; pub mod probe;
pub mod process; pub mod process;
+383
View File
@@ -0,0 +1,383 @@
//! Nushell-inspired navigation helpers: a boxed table listing of files and
//! directories (ls), and directory/path completion for Tab.
use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};
/// Human-readable byte size, like Nushell ("512 B", "1.2 KB", "3.4 MB").
pub fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * 1024.0;
const GB: f64 = 1024.0 * 1024.0 * 1024.0;
let b = bytes as f64;
if b < KB {
format!("{bytes} B")
} else if b < MB {
format!("{:.1} KB", b / KB)
} else if b < GB {
format!("{:.1} MB", b / MB)
} else {
format!("{:.1} GB", b / GB)
}
}
/// Recursive size of a directory, bounded: beyond the entry cap the size is
/// reported as unknown (None) so huge trees never freeze the listing.
pub fn dir_size(path: &Path) -> Option<u64> {
const CAP: usize = 20_000;
let mut total: u64 = 0;
let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
let mut visited: usize = 0;
while let Some(dir) = stack.pop() {
let rd = std::fs::read_dir(&dir).ok()?;
for entry in rd.flatten() {
visited += 1;
if visited > CAP {
return None;
}
let p = entry.path();
match std::fs::symlink_metadata(&p) {
Ok(m) if m.is_dir() => stack.push(p),
Ok(m) if m.is_file() => {
total = total.saturating_add(m.len());
}
_ => {}
}
}
}
Some(total)
}
/// One entry of the ls table.
struct LsRow {
name: String,
kind: &'static str,
size: String,
modified: String,
}
fn modified_str(path: &Path) -> String {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.map(|t| {
let dt: chrono::DateTime<chrono::Local> = t.into();
dt.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_else(|| "—".to_string())
}
fn paint(s: &str, color: bool, style: u8) -> String {
if !color {
return s.to_string();
}
match style {
1 => s.blue().bold().to_string(), // directory
2 => s.green().to_string(), // executable
3 => s.cyan().to_string(), // symlink
4 => s.red().to_string(), // broken symlink
_ => s.to_string(),
}
}
/// Render a Nushell-style boxed table of the entries of a path, with the
/// columns: index, name, type, size, modified. Directories come first, then
/// alphabetical order; sizes are human-readable and directories show their
/// recursive size (bounded).
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
let rd = std::fs::read_dir(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut rows: Vec<LsRow> = Vec::new();
for entry in rd.flatten() {
let p = entry.path();
let meta = match std::fs::symlink_metadata(&p) {
Ok(m) => m,
Err(_) => continue,
};
let file_type = meta.file_type();
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let (kind, style) = if file_type.is_symlink() {
("symlink", if p.exists() { 3 } else { 4 })
} else if file_type.is_dir() {
("dir", 1)
} else if is_executable(&p, &meta) {
("file", 2)
} else {
("file", 0)
};
let size = if file_type.is_dir() {
match dir_size(&p) {
Some(s) => format_size(s),
None => "—".to_string(),
}
} else {
format_size(meta.len())
};
let mut display = name.clone();
if file_type.is_dir() {
display.push('/');
} else if file_type.is_symlink() {
if let Ok(target) = std::fs::read_link(&p) {
display.push_str(&format!(" -> {}", target.display()));
}
}
rows.push(LsRow {
name: paint(&display, color, style),
kind,
size,
modified: modified_str(&p),
});
}
// Directories first, then alphabetical (case-insensitive).
rows.sort_by(|a, b| {
let a_dir = a.kind == "dir";
let b_dir = b.kind == "dir";
b_dir
.cmp(&a_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
// Column contents: index, name, type, size, modified.
let headers = vec!["#", "name", "type", "size", "modified"];
let mut widths: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
let mut cells: Vec<Vec<String>> = Vec::new();
for (i, row) in rows.iter().enumerate() {
let line = vec![
i.to_string(),
row.name.clone(),
row.kind.to_string(),
row.size.clone(),
row.modified.clone(),
];
for (c, cell) in line.iter().enumerate() {
widths[c] = widths[c].max(visible_len(cell));
}
cells.push(line);
}
let pad = |s: &str, w: usize| -> String {
let n = visible_len(s);
format!("{s}{}", " ".repeat(w.saturating_sub(n)))
};
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
for (i, w) in widths.iter().enumerate() {
if i > 0 {
line.push(mid);
}
line.push_str(&"─".repeat(w + 2));
}
line.push(right);
line
};
let row = |cells: &[String], bold: bool| -> String {
let mut line = String::from("│");
for (i, c) in cells.iter().enumerate() {
line.push(' ');
let cell = pad(c, widths[i]);
let rendered = if bold {
cell.bold().to_string()
} else {
cell
};
line.push_str(&rendered);
line.push(' ');
line.push('│');
}
line
};
let dim = |s: &str| -> String {
if color {
s.dimmed().to_string()
} else {
s.to_string()
}
};
let mut out = String::new();
out.push_str(&dim(&border('╭', '┬', '╮')));
out.push('\n');
out.push_str(&row(&headers.iter().map(|h| h.to_string()).collect::<Vec<_>>(), true));
out.push('\n');
out.push_str(&dim(&border('├', '┼', '┤')));
out.push('\n');
for line in &cells {
out.push_str(&row(line, false));
out.push('\n');
}
out.push_str(&dim(&border('╰', '┴', '╯')));
out.push('\n');
Ok(out)
}
fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
{
let _ = meta;
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref(),
Some("exe") | Some("bat") | Some("cmd") | Some("com")
)
}
}
/// Completion candidates for a path fragment typed after cd or ls.
///
/// The last segment is matched against the entries of the base directory;
/// directory candidates end with a separator so the next Tab dives one level
/// deeper, like Nushell. With dirs_only, files are excluded (cd).
pub fn path_candidates(base_dir: &Path, input: &str, dirs_only: bool) -> Vec<String> {
let sep = std::path::MAIN_SEPARATOR;
let alt = if sep == '\\' { '/' } else { '\\' };
let idx = input.rfind(|c| c == sep || c == alt);
let (base_part, stem) = match idx {
Some(i) => (&input[..=i], &input[i + 1..]),
None => ("", input),
};
let use_sep = if input.contains(alt) && !input.contains(sep) {
alt
} else {
sep
};
let base: PathBuf = if base_part.starts_with('~') {
crate::config::expand_path(base_part)
} else if base_part.is_empty() {
base_dir.to_path_buf()
} else {
PathBuf::from(base_part)
};
let mut names: Vec<(String, bool)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&base) {
for entry in rd.flatten() {
let p = entry.path();
let is_dir = p.is_dir();
if dirs_only && !is_dir {
continue;
}
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.starts_with(stem) {
continue;
}
names.push((name.to_string(), is_dir));
}
}
names.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
names
.into_iter()
.map(|(name, is_dir)| {
let mut full = format!("{base_part}{name}");
if is_dir {
full.push(use_sep);
}
full
})
.collect()
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn visible_len(s: &str) -> usize {
strip_ansi(s).chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_sizes() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(2048), "2.0 KB");
assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
assert_eq!(format_size(3 * 1024 * 1024 * 1024), "3.0 GB");
}
#[test]
fn completes_directories_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join("src2")).unwrap();
std::fs::write(dir.path().join("readme.txt"), "x").unwrap();
let cands = path_candidates(dir.path(), "", true);
assert!(cands.iter().any(|c| c == "src\\" || c == "src/"));
assert!(cands.iter().any(|c| c == "src2\\" || c == "src2/"));
assert!(!cands.iter().any(|c| c.contains("readme")));
let cands = path_candidates(dir.path(), "s", true);
assert_eq!(cands.len(), 2, "{cands:?}");
// Files appear for ls completion.
let cands = path_candidates(dir.path(), "", false);
assert!(cands.iter().any(|c| c.contains("readme")));
}
#[test]
fn completes_nested_paths() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src").join("commands")).unwrap();
std::fs::create_dir_all(dir.path().join("src").join("installers")).unwrap();
let sep = std::path::MAIN_SEPARATOR;
let prefix = format!("src{}", sep);
let cands = path_candidates(dir.path(), &format!("src{}co", sep), true);
assert!(cands.iter().any(|c| c == &format!("{prefix}commands{}", sep)));
assert!(!cands.iter().any(|c| c.contains("installers")));
}
#[test]
fn renders_ls_table() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("subdir")).unwrap();
std::fs::write(dir.path().join("file.txt"), "hello").unwrap();
let table = render_ls(dir.path(), false).unwrap();
assert!(table.contains("╭"));
assert!(table.contains("╰"));
assert!(table.contains("name"));
assert!(table.contains("type"));
assert!(table.contains("modified"));
assert!(table.contains("subdir/"));
assert!(table.contains("file.txt"));
assert!(table.contains("dir"));
assert!(table.contains("file"));
assert!(table.contains("5 B"));
// Every line of the table has the same width.
let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned table:\n{table}");
}
}
+105 -8
View File
@@ -15,6 +15,7 @@
use crate::app::App; use crate::app::App;
use crate::cli::{Cli, Command, StartArgs}; use crate::cli::{Cli, Command, StartArgs};
use crate::commands; use crate::commands;
use crate::nav;
use crate::shell::ShellSession; use crate::shell::ShellSession;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use clap::CommandFactory; use clap::CommandFactory;
@@ -44,8 +45,39 @@ pub struct AmCompleter {
agents: Vec<String>, agents: Vec<String>,
aliases: Vec<String>, aliases: Vec<String>,
groups: Vec<String>, groups: Vec<String>,
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
} }
/// One-line descriptions for the Tab completion menu.
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("list", "list installed agents"),
("status", "show agent state"),
("search", "search the catalog"),
("info", "agent details"),
("install", "install an agent"),
("uninstall", "remove an agent"),
("update", "update agents"),
("start", "start an agent"),
("stop", "stop an agent"),
("restart", "restart an agent"),
("run", "run an agent command"),
("doctor", "check the environment"),
("config", "manage the configuration"),
("completion", "generate a completion script"),
("self-update", "update agent-manager"),
("self-uninstall", "remove agent-manager"),
("export", "backup config + state"),
("import", "restore a backup"),
("shell", "show or switch the system shell"),
("ls", "list files and directories (Nushell-style)"),
("cd", "change directory — Tab completes folders"),
("help", "show this help"),
("version", "print the version"),
("exit", "quit the shell"),
];
impl AmCompleter { impl AmCompleter {
pub fn new(app: &App) -> Self { pub fn new(app: &App) -> Self {
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups) Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
@@ -67,12 +99,27 @@ impl AmCompleter {
alias_list.sort(); alias_list.sort();
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect(); let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
group_list.sort(); group_list.sort();
let mut descriptions: BTreeMap<String, String> = BTreeMap::new();
for (cmd, desc) in COMMAND_DESCRIPTIONS {
descriptions.insert((*cmd).to_string(), (*desc).to_string());
}
for a in catalog.agents() {
if a.hidden {
continue;
}
let desc = a
.category
.clone()
.or_else(|| a.description.clone())
.unwrap_or_default();
descriptions.insert(a.name.clone(), truncate_desc(&desc));
}
Self { Self {
commands: vec![ commands: vec![
"list", "status", "search", "info", "install", "uninstall", "update", "list", "status", "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", "help", "self-update", "self-uninstall", "export", "import", "shell", "ls", "cd",
"version", "exit", "help", "version", "exit",
], ],
config_sub: vec!["show", "path", "edit", "validate", "add"], config_sub: vec!["show", "path", "edit", "validate", "add"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"], shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
@@ -88,6 +135,7 @@ impl AmCompleter {
agents, agents,
aliases: alias_list, aliases: alias_list,
groups: group_list, groups: group_list,
descriptions,
} }
} }
@@ -118,6 +166,16 @@ impl AmCompleter {
"shell" => { "shell" => {
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect(); pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
} }
"cd" | "chdir" => {
// Complete directories only — dive one level per Tab.
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, true);
}
"ls" => {
// Complete paths (files and directories).
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, false);
}
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run" "install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
| "info" | "status" => { | "info" | "status" => {
if word.starts_with("group:") { if word.starts_with("group:") {
@@ -172,9 +230,17 @@ impl Completer for AmCompleter {
let (start, cands) = self.candidates_for(line, pos); let (start, cands) = self.candidates_for(line, pos);
let pairs = cands let pairs = cands
.into_iter() .into_iter()
.map(|c| Pair { .map(|c| {
display: c.clone(), // Nushell-style menu: show a description next to the
replacement: c, // 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,
replacement: c,
}
}) })
.collect(); .collect();
Ok((start, pairs)) Ok((start, pairs))
@@ -340,7 +406,7 @@ pub fn banner_box(
" system doctor · config · completion · self-update · self-uninstall · export · import" " system doctor · config · completion · self-update · self-uninstall · export · import"
.to_string(), .to_string(),
)); ));
rows.push(inner(" shell shell · help · version · exit".to_string())); rows.push(inner(" shell ls · cd · shell · help · version · exit".to_string()));
// Stats footer. // Stats footer.
rows.push(blank()); rows.push(blank());
@@ -536,6 +602,20 @@ fn current_dir_string() -> String {
.unwrap_or_else(|_| "?".to_string()) .unwrap_or_else(|_| "?".to_string())
} }
fn current_dir() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
/// Keep a catalog description short enough for the completion menu.
fn truncate_desc(desc: &str) -> String {
let one_line = desc.lines().next().unwrap_or(desc);
let mut out: String = one_line.chars().take(64).collect();
if one_line.chars().count() > 64 {
out.push('…');
}
out
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Interactive loop // Interactive loop
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -682,6 +762,11 @@ fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<bool
shell_command(session, rest.first().map(|s| s.as_str())); shell_command(session, rest.first().map(|s| s.as_str()));
return Ok(false); return Ok(false);
} }
// Nushell-style directory listing (the system ls stays available as !ls).
if cmd == "ls" {
ls_command(app, rest)?;
return Ok(false);
}
// Everything that is not an am command is a system command. // Everything that is not an am command is a system command.
if !is_am_command(cmd) { if !is_am_command(cmd) {
run_system(app, session, line)?; run_system(app, session, line)?;
@@ -825,6 +910,16 @@ fn is_am_command(word: &str) -> bool {
) )
} }
/// Nushell-style listing of the current (or given) directory.
fn ls_command(app: &App, rest: &[String]) -> Result<()> {
let target = match rest.first() {
Some(p) => crate::config::expand_path(p),
None => current_dir(),
};
print!("{}", nav::render_ls(&target, app.color())?);
Ok(())
}
/// Run one system command through the active shell (inherited stdio). /// Run one system command through the active shell (inherited stdio).
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> { fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
app.log.cmd(&format!("{}: {line}", session.current.name)); app.log.cmd(&format!("{}: {line}", session.current.name));
@@ -904,12 +999,14 @@ fn print_help() {
println!(" export [--output FILE] backup config + state"); println!(" export [--output FILE] backup config + state");
println!(" import <file> restore a backup"); println!(" import <file> restore a backup");
println!(" shell [list|<name>] show or switch the system shell"); println!(" shell [list|<name>] show or switch the system shell");
println!(" ls [path] list files and directories (Nushell-style table)");
println!(" cd <dir> change directory — Tab completes folders");
println!(" help | version | exit this help, the version, quit"); println!(" help | version | exit this help, the version, quit");
println!(); println!();
println!("system gateway:"); println!("system gateway:");
println!(" any other input runs as a system command in the active shell"); println!(" any other input runs as a system command in the active shell");
println!(" prefix with '!' to force system execution (e.g. '!list')"); println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')");
println!(" 'cd <dir>' changes the session directory (in-process)"); println!(" Tab completes commands, agents, flags and paths (cycle with Tab)");
println!(); println!();
println!("slash commands: /help · /version · /exit · /shell <name>"); println!("slash commands: /help · /version · /exit · /shell <name>");
} }