From 3e1b66809aa28b4f0a3f58e7024c6616db3860b1 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 15 Aug 2026 23:42:32 -0400 Subject: [PATCH] =?UTF-8?q?v0.2.8=20:=20navigation=20facon=20Nushell=20?= =?UTF-8?q?=E2=80=94=20commande=20ls=20en=20tableau=20encadre=20(type,=20t?= =?UTF-8?q?aille,=20date),=20completion=20Tab=20des=20dossiers=20pour=20cd?= =?UTF-8?q?=20(niveau=20par=20niveau)=20et=20des=20chemins=20pour=20ls,=20?= =?UTF-8?q?descriptions=20dans=20le=20menu=20Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 10 +- src/lib.rs | 1 + src/nav.rs | 383 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/repl.rs | 113 ++++++++++++++-- 6 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 src/nav.rs diff --git a/Cargo.lock b/Cargo.lock index a471dd5..1bcc5e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.7" +version = "0.2.8" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0832d22..9d2fadb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.7" +version = "0.2.8" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/README.md b/README.md index 02bae04..2f81d94 100644 --- a/README.md +++ b/README.md @@ -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 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 # complétion des dossiers uniquement, un niveau par Tab ❯ shell # affiche le shell courant + les shells disponibles ❯ 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) ❯ /help # commandes slash : /help · /version · /exit · /shell +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 `config.yaml` (voir `am config path`). diff --git a/src/lib.rs b/src/lib.rs index 89624f2..f8df5a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod deps; pub mod doctor; pub mod download; pub mod installers; +pub mod nav; pub mod output; pub mod probe; pub mod process; diff --git a/src/nav.rs b/src/nav.rs new file mode 100644 index 0000000..2901430 --- /dev/null +++ b/src/nav.rs @@ -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 { + const CAP: usize = 20_000; + let mut total: u64 = 0; + let mut stack: Vec = 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 = 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 { + let rd = std::fs::read_dir(path) + .with_context(|| format!("cannot read {}", path.display()))?; + let mut rows: Vec = 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 = headers.iter().map(|h| visible_len(h)).collect(); + let mut cells: Vec> = 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::>(), 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 { + 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 = table.lines().map(|l| l.chars().count()).collect(); + assert!(widths.iter().all(|w| *w == widths[0]), "unaligned table:\n{table}"); + } +} diff --git a/src/repl.rs b/src/repl.rs index d9514b7..9755565 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -15,6 +15,7 @@ use crate::app::App; use crate::cli::{Cli, Command, StartArgs}; use crate::commands; +use crate::nav; use crate::shell::ShellSession; use anyhow::{anyhow, Result}; use clap::CommandFactory; @@ -44,8 +45,39 @@ pub struct AmCompleter { agents: Vec, aliases: Vec, groups: Vec, + /// Short descriptions shown next to candidates in the Tab menu + /// (Nushell-style). + descriptions: BTreeMap, } +/// 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 { pub fn new(app: &App) -> Self { Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups) @@ -67,12 +99,27 @@ impl AmCompleter { alias_list.sort(); let mut group_list: Vec = groups.keys().map(|g| format!("group:{g}")).collect(); group_list.sort(); + let mut descriptions: BTreeMap = 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 { commands: vec![ "list", "status", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", - "self-update", "self-uninstall", "export", "import", "shell", "help", - "version", "exit", + "self-update", "self-uninstall", "export", "import", "shell", "ls", "cd", + "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], shells: vec!["bash", "zsh", "fish", "powershell", "elvish"], @@ -88,6 +135,7 @@ impl AmCompleter { agents, aliases: alias_list, groups: group_list, + descriptions, } } @@ -118,6 +166,16 @@ impl AmCompleter { "shell" => { 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" | "info" | "status" => { if word.starts_with("group:") { @@ -172,9 +230,17 @@ impl Completer for AmCompleter { let (start, cands) = self.candidates_for(line, pos); let pairs = cands .into_iter() - .map(|c| Pair { - display: c.clone(), - replacement: c, + .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, + replacement: c, + } }) .collect(); Ok((start, pairs)) @@ -340,7 +406,7 @@ pub fn banner_box( " system doctor · config · completion · self-update · self-uninstall · export · import" .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. rows.push(blank()); @@ -536,6 +602,20 @@ fn current_dir_string() -> 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 // --------------------------------------------------------------------------- @@ -682,6 +762,11 @@ fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result 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). fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> { app.log.cmd(&format!("{}: {line}", session.current.name)); @@ -904,12 +999,14 @@ fn print_help() { println!(" export [--output FILE] backup config + state"); println!(" import restore a backup"); println!(" shell [list|] show or switch the system shell"); + println!(" ls [path] list files and directories (Nushell-style table)"); + println!(" cd change directory — Tab completes folders"); println!(" help | version | exit this help, the version, quit"); 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. '!list')"); - println!(" 'cd ' changes the session directory (in-process)"); + println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')"); + println!(" Tab completes commands, agents, flags and paths (cycle with Tab)"); println!(); println!("slash commands: /help · /version · /exit · /shell "); }