From 2c079b0ccde29682e6fd0632a4cf340eec1a0a03 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 13:08:50 -0400 Subject: [PATCH] v0.4.2 : menu de completion Nushell (reedline) + correction des chemins Windows - remplace rustyline par reedline (menu Tab interactif : 1er choix surbrille, Tab navigue, Entree valide, Esc ferme, filtrage en direct, descriptions, couleurs du theme), tokenizer Windows preservant les backslashes pour cd/ls (shell_words mangeait les \), historique history.txt conserve, repli sans terminal, tests + man pages regeneres --- ARCHITECTURE.md | 688 +++++++++++++++++++++++++++++++++++ Cargo.lock | 175 +++++---- Cargo.toml | 5 +- README.md | 13 +- RELEASING.md | 10 +- man/am-self-update.1 | 4 +- man/am.1 | 6 +- scripts/render-manifests.ps1 | 2 +- src/dashboard.rs | 3 + src/output.rs | 89 ----- src/repl.rs | 574 +++++++++++++++++++---------- 11 files changed, 1198 insertions(+), 371 deletions(-) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ce63d2e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,688 @@ +# đŸ—ïž Document d'architecture — agent-manager (`am`) + +> Ce document dĂ©crit la conception, le fonctionnement et les fonctionnalitĂ©s de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gĂ©rer des agents IA de coding locaux. +> +> 📩 Version : `0.4.2` · 🩀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS + +--- + +## 🎯 Vue d'ensemble + +`am` est un gestionnaire d'agents IA de coding qui permet de : + +- 📋 DĂ©couvrir et lister plus de **72 agents** via un catalogue YAML embarquĂ© +- 📩 Les installer avec **9 mĂ©thodes** diffĂ©rentes (npm, pip, uv, cargo, go, bun, curl, binaire, git) +- 🚀 Les dĂ©marrer, arrĂȘter, redĂ©marrer en avant-plan ou en arriĂšre-plan +- 🔍 Observer leur Ă©tat, leurs logs, leurs statistiques d'utilisation +- đŸ› ïž GĂ©rer dĂ©pendances, alias, groupes, profils, secrets, favoris et annotations + +Le tout sans toucher au systĂšme : chaque agent est installĂ© dans un rĂ©pertoire utilisateur isolĂ©. + +--- + +## đŸ§© Architecture globale + +```mermaid +flowchart TB + subgraph UI["đŸ–„ïž Interfaces utilisateur"] + CLI["Ligne de commande
clap v4"] + REPL["Shell interactif
rustyline"] + TUI["Dashboard TUI
ratatui + crossterm"] + end + + subgraph CORE["⚙ Noyau applicatif"] + APP["App
contexte partagé"] + CLI_DEF["cli.rs
définition des commandes"] + CMD["commands/
dispatch"] + end + + subgraph DATA["đŸ’Ÿ DonnĂ©es persistantes"] + CONFIG["Configuration YAML
embarquée + utilisateur"] + CATALOG["Catalog
index agents/alias/groupes"] + STATE["StateStore
state.json"] + EVENTS["Journal JSONL
events-YYYYMM.jsonl"] + CACHE["Probe cache
probe-cache.json"] + end + + subgraph EXEC["🔧 ExĂ©cution"] + RUNNER["Runner trait
systĂšme / mock"] + INSTALLERS["installers/
9 méthodes"] + DEPS["deps.rs
vérification dépendances"] + PROCESS["process.rs
PID / signaux"] + end + + subgraph OBS["đŸ‘ïž Observation"] + PROBE["probe.rs
détection agents externes"] + DASH["dashboard.rs
agrégation"] + STATS["stats.rs
statistiques"] + SESSIONS["sessions.rs
registre sessions"] + end + + CLI --> APP + REPL --> APP + TUI --> APP + APP --> CLI_DEF + APP --> CONFIG + APP --> CATALOG + APP --> STATE + APP --> EVENTS + APP --> CACHE + CMD --> RUNNER + CMD --> INSTALLERS + CMD --> DEPS + CMD --> PROCESS + CMD --> PROBE + CMD --> DASH + CMD --> STATS + CMD --> SESSIONS +``` + +--- + +## 🚀 Flux d'exĂ©cution d'une commande + +```mermaid +sequenceDiagram + autonumber + participant User + participant main as main.rs + participant lib as lib.rs + participant help as help.rs + participant cli as cli.rs + participant app as app.rs + participant cfg as config.rs + participant state as state.rs + participant cmd as commands/mod.rs + participant impl as commands/xxx_cmd.rs + + User->>main: am install jcode + main->>lib: main_entry() + lib->>lib: spawn thread 8 Mo + lib->>help: intercept(-h/--help) ? + help-->>lib: None + lib->>cli: Cli::parse() + cli-->>lib: Cli { command: Install {...} } + lib->>app: App::from_cli(cli) + app->>cfg: load() config embarquĂ©e + user + cfg-->>app: Config + app->>state: StateStore::new() + state-->>app: state.json chargĂ© + app-->>lib: App + lib->>cmd: commands::execute(&app) + cmd->>impl: install_cmd::run(...) + impl->>impl: vĂ©rifier dĂ©pendances, installer + impl->>state: persister + impl->>app: emit(event install) + impl-->>cmd: Ok(0) + cmd-->>lib: 0 + lib-->>main: code sortie +``` + +--- + +## 📁 Structure du code source + +``` +src/ +├── main.rs đŸšȘ Point d'entrĂ©e +├── lib.rs 🧭 main_entry(), doc du crate +├── cli.rs 📋 DĂ©finition clap de toutes les commandes +├── app.rs 🧰 Contexte App (config, state, paths, logger, theme) +├── config.rs ⚙ SchĂ©ma YAML, chargement, fusion, validation +├── catalog.rs 🔍 Index agents + recherche fuzzy + suggestions +├── state.rs đŸ’Ÿ Base JSON des installations et annotations +├── events.rs 📝 Journal d'Ă©vĂ©nements JSONL +├── runner.rs 🏃 Trait d'exĂ©cution systĂšme / mock +├── process.rs ⚙ Lancement, arrĂȘt, signaux des processus +├── deps.rs ✅ VĂ©rification et auto-installation des dĂ©pendances +├── toolchain.rs đŸ–„ïž DĂ©tection OS / gestionnaire de paquets +├── download.rs âŹ‡ïž TĂ©lĂ©chargement, checksums, extraction +├── probe.rs 🔎 DĂ©tection des agents externes sur le PATH +├── dashboard.rs 📊 DonnĂ©es agrĂ©gĂ©es du dashboard +├── ps.rs 🧼 Table des processus +├── context.rs 📂 Contexte du projet courant +├── repl.rs 💬 Shell interactif +├── shell.rs 🐚 Gestion des shells supportĂ©s +├── help.rs ❓ Aide Nushell-style +├── output.rs đŸ–šïž Logger et rendu +├── tables.rs 📋 Rendu tabulaire +├── theme.rs 🎹 ThĂšmes de couleur +├── history.rs âȘ Historique des commandes +├── sessions.rs 📅 Registre des sessions +├── projects.rs đŸ—‚ïžïž AgrĂ©gation par projet +├── hooks.rs đŸȘ Hooks de cycle de vie +├── secrets.rs 🔒 Gestion des secrets (keyring OS) +├── version.rs đŸ·ïž Informations de version +├── commands/ 📩 35 modules, un par commande +└── installers/ 📩 9 installateurs spĂ©cialisĂ©s +``` + +--- + +## ⚙ Configuration : catalogue YAML extensible + +### HiĂ©rarchie de chargement + +```mermaid +flowchart LR + EMB["📩 config.yaml
embarquĂ© dans le binaire"] --> MERGE["🔀 Fusion"] + USER["đŸ‘€ ~/.config/agent-manager/config.yaml"] --> MERGE + LOCAL["📂 ./agent-manager.yaml"] --> MERGE + FLAG["⚡ --config "] --> MERGE + MERGE --> EFFECTIVE["✅ Configuration effective"] +``` + +PrioritĂ© (la plus prioritaire en dernier) : + +1. `config.yaml` embarquĂ© (catalogue par dĂ©faut, ~72 agents) +2. `~/.config/agent-manager/config.yaml` +3. `./agent-manager.yaml` (configuration locale par projet) +4. `--config ` + +### SchĂ©ma de configuration + +```yaml +version: "1.0" + +settings: + install_dir: null # ~/.local/share/agent-manager/agents + log_dir: null # ~/.local/state/agent-manager/logs + default_shell: null # shell utilisateur par dĂ©faut + auto_install_deps: true # proposer d'installer les dĂ©pendances manquantes + confirm_before_run: true # confirmer avant les scripts d'installation + stop_timeout_secs: 5 # dĂ©lai SIGTERM → SIGKILL + self_update_repo: Projets/agent-manager + self_update_base_url: https://git.dracodev.net/api/v1 + theme: null + hooks: + on_install: [] + on_start: [] + on_stop: [] + on_update: [] + +aliases: + cc: claude-code + gemini: gemini-cli + +groups: + dev: [claude-code, aider, codex] + +include: + - ./extra-agents.yaml + +agents: + - name: claude-code + display_name: "Claude Code" + description: "..." + category: coding-agent + website: https://github.com/anthropics/claude-code + install: + type: npm + package: "@anthropic-ai/claude-code" + dependencies: + - { name: node, min_version: "18.0.0" } + run: claude + tags: [anthropic, assistant] + +profiles: + dev: + agent: claude-code + env: { API_ENV: dev } + args: [--verbose] + +projects: + mon-projet: + root: ~/projets/mon-projet + default_agent: claude-code + env: { KEY: value } +``` + +### DĂ©finition d'un agent (`AgentDef`) + +| Champ | Description | +|-------|-------------| +| `name` | Identifiant unique (slug) | +| `display_name` | Nom lisible | +| `description` | Description longue | +| `category` | CatĂ©gorie (coding-agent, assistant, local-first...) | +| `website` | URL du projet | +| `install` | SpĂ©cification d'installation | +| `dependencies` | Outils requis (node, python, go...) avec version min | +| `run` | Commande de lancement | +| `args` | Arguments par dĂ©faut | +| `env` | Variables d'environnement par dĂ©faut | +| `tags` | Tags pour recherche/filtrage | +| `installable` | `false` pour les agents SaaS/Desktop | +| `platforms` | Restriction `linux`/`macos`/`windows` | + +### MĂ©thodes d'installation supportĂ©es + +| Type | Fichier | Principe | +|------|---------|----------| +| `npm` | `installers/npm.rs` | `npm install -g --prefix ` | +| `bun` | `installers/npm.rs` | `bun install -g` | +| `pip` / `uv` | `installers/pipuv.rs` | virtualenv privĂ© dans `install_dir` | +| `cargo` | `installers/cargo.rs` | `cargo install --root` | +| `go` | `installers/golang.rs` | `go install` avec `GOBIN` local | +| `curl` | `installers/script.rs` | TĂ©lĂ©charge et exĂ©cute un script | +| `binary` | `installers/binary.rs` | Release GitHub/Gitea → extraction archive | +| `git` | `installers/git.rs` | Clone + build + `binary_path` | + +--- + +## đŸ’Ÿ Gestion des Ă©tats + +### `state.json` — base d'installation locale + +Format JSON version 3, atomique (Ă©criture `.tmp` + `rename`). + +```json +{ + "version": 3, + "installed": { + "jcode": { + "name": "jcode", + "version": "0.76.0", + "method": "binary", + "directory": ".../agents/jcode", + "binaries": ["jcode"], + "pid": null, + "installed_at": "...", + "updated_at": "..." + } + }, + "annotations": { + "jcode": { + "favorite": true, + "note": "mon agent prĂ©fĂ©rĂ©", + "tags": ["perso"] + } + } +} +``` + +### Journal d'Ă©vĂ©nements JSONL + +- Un fichier par mois : `events-YYYYMM.jsonl` +- Append-only, horodatĂ© +- Types d'Ă©vĂ©nements : `start`, `stop`, `run`, `install`, `update`, `uninstall`, `doctor`, `config`, `repl`, `shell`, `annotate` +- Les secrets ne sont jamais journalisĂ©s +- Source de vĂ©ritĂ© pour les statistiques, sessions, projets et timeline + +### Cache de sondes + +`probe-cache.json` Ă©vite de re-scanner le PATH Ă  chaque commande pour dĂ©tecter les agents externes et leurs versions. + +--- + +## đŸ›Ąïž Gestion des dĂ©pendances + +```mermaid +flowchart LR + INSTALL["am install jcode"] --> CHECK["deps::check_dep"] + CHECK --> MISSING{Manquant ?} + MISSING -->|Oui| DETECT["toolchain::detect OS"] + DETECT --> COMMANDS["toolchain::install_commands"] + COMMANDS --> PROMPT["Proposer la commande"] + PROMPT --> AUTO["ExĂ©cuter si --yes"] + AUTO --> INSTALL2["installers::run_install"] + MISSING -->|Non| INSTALL2 +``` + +- `deps.rs` vĂ©rifie chaque dĂ©pendance via ` --version` +- `toolchain.rs` dĂ©tecte l'OS et le gestionnaire de paquets : + - Windows : `scoop` → `winget` + - Linux : `apt` / `dnf` / `pacman` / `apk` + - macOS : `brew` +- Si `auto_install_deps: true`, `am` propose et peut exĂ©cuter la commande d'installation +- Les dĂ©pendances de l'agent (modules npm/pip) restent isolĂ©es dans le rĂ©pertoire `install_dir` + +--- + +## ⚡ Gestion des processus + +```mermaid +flowchart TB + START["am start "] --> MODE{Mode ?} + MODE -->|Foreground| FG["Attache au terminal
Ctrl-C pour quitter"] + MODE -->|Background| BG["spawn_background()"] + BG --> LOG["Redirection stdout/stderr
vers log agent"] + LOG --> PID["Enregistrement PID
dans state.json"] + PID --> EVENT["Émission Ă©vĂ©nement start"] + + STOP["am stop "] --> SIGTERM["SIGTERM"] + SIGTERM --> WAIT{"Processus terminĂ© ?"} + WAIT -->|Non| SIGKILL["SIGKILL aprĂšs timeout"] + WAIT -->|Oui| CLEAN["PID effacĂ©"] +``` + +- DĂ©marrage avant-plan (`-f`) ou arriĂšre-plan (`-b`, `--background`) +- Variables d'environnement injectĂ©es via `--env KEY=VALUE` +- Secrets via `--env KEY=@secret` (rĂ©solu depuis le trousseau OS) +- ArrĂȘt gracieux avec timeout configurable (dĂ©faut 5 s) +- Notifications desktop optionnelles (`--notify`) + +--- + +## 📋 Commandes dĂ©taillĂ©es + +### 🔍 DĂ©couvrir + +| Commande | Description | +|----------|-------------| +| `am list` | Agents installĂ©s + dĂ©tectĂ©s sur le PATH | +| `am list --all` | Tout le catalogue avec Ă©tat | +| `am search ` | Recherche fuzzy avec suggestions | +| `am info ` | Fiche dĂ©taillĂ©e | +| `am status [agent]` | État, version, PID, logs | + +### 📩 Cycle de vie + +| Commande | Description | +|----------|-------------| +| `am install ` | Installe un agent et ses dĂ©pendances | +| `am install --method pip` | Choisit la mĂ©thode | +| `am uninstall [--purge]` | DĂ©sinstalle et nettoie | +| `am update ` / `--all` | Met Ă  jour | +| `am start [agent]` | Lance en avant-plan | +| `am start -b` | Lance en arriĂšre-plan | +| `am stop ` | ArrĂȘt gracieux | +| `am restart ` | RedĂ©marrage | +| `am run [args...]` | ExĂ©cution directe sans gestion de PID | + +### đŸ‘ïž Observer + +| Commande | Description | +|----------|-------------| +| `am logs ` | Tail du log agent | +| `am log [agent]` | Journal des Ă©vĂ©nements `am` | +| `am timeline` | Vue chronologique unifiĂ©e | +| `am sessions [agent]` | Registre des sessions | +| `am stats [agent] --period 7d` | Statistiques d'utilisation | +| `am top --period 30d` | Top 10 agents | +| `am report --last-week` | Rapport markdown | +| `am projects [nom]` | Agents par projet | +| `am dashboard` | Dashboard TUI temps rĂ©el | +| `am watch --restart` | Supervision et relance automatique | + +### 🎯 Personnaliser + +| Commande | Description | +|----------|-------------| +| `am favorite ` | Marquer comme favori | +| `am note ` | Ajouter une note | +| `am tag ` | Taguer | +| `am tags [agent]` | Lister les tags | +| `am profile list/show ` | Profils d'environnement | +| `am alias add ` | CrĂ©er un alias | +| `am secret set --agent --value ` | Secret dans le trousseau | + +### đŸ› ïž SystĂšme + +| Commande | Description | +|----------|-------------| +| `am doctor [--fix]` | Diagnostic environnement | +| `am config show/path/edit/validate/set` | Gestion config | +| `am completion ` | Script de complĂ©tion | +| `am man [commande]` | Page de manuel | +| `am open ` | Ouvrir le rĂ©pertoire d'installation | +| `am export/import` | Sauvegarde/restauration | +| `am self-update [--check]` | Mise Ă  jour de `am` | +| `am self-uninstall` | DĂ©sinstallation complĂšte | +| `am version` | Version et build | +| `am` (sans commande) | REPL interactif | + +--- + +## 🌐 Options globales + +Disponibles avant ou aprĂšs la sous-commande. + +| Option | Effet | +|--------|-------| +| `-c, --config ` | Fichier de configuration alternatif | +| `-v, --verbose` | Affiche chaque commande exĂ©cutĂ©e | +| `-q, --quiet` | Seules les erreurs sont affichĂ©es | +| `-y, --yes` | Oui Ă  toutes les confirmations | +| `--dry-run` | Simulation sans modification | +| `--json` | Sortie JSON structurĂ©e | +| `--no-color` | DĂ©sactive les couleurs | +| `--theme ` | ThĂšme de couleur | + +--- + +## 🎹 FonctionnalitĂ©s avancĂ©es + +### Alias + +```yaml +aliases: + cc: claude-code + gemini: gemini-cli +``` + +`am start cc` dĂ©marre `claude-code`. + +### Groupes + +```yaml +groups: + dev: [claude-code, aider, codex] +``` + +`am start group:dev` dĂ©marre tous les agents du groupe en arriĂšre-plan. + +### Profils d'environnement + +```yaml +profiles: + dev: + agent: claude-code + env: { API_ENV: dev } + args: [--verbose] +``` + +`am start --profile dev` lance `claude-code` avec les variables et arguments du profil. + +### Projets + +```yaml +projects: + mon-projet: + root: ~/projets/mon-projet + default_agent: claude-code +``` + +Dans le rĂ©pertoire du projet, `am start` sans argument lance l'agent par dĂ©faut du projet. + +### Secrets + +Les secrets sont stockĂ©s dans le trousseau du systĂšme d'exploitation (via `keyring`), jamais en clair dans la config. + +```bash +am secret set OPENAI_API_KEY --agent claude-code --value sk-... +am start claude-code --env OPENAI_API_KEY=@secret +``` + +### Hooks + +Commandes exĂ©cutĂ©es automatiquement aux Ă©tapes clĂ©s du cycle de vie : + +- `on_install` +- `on_start` +- `on_stop` +- `on_update` + +DĂ©finissables globalement dans `settings.hooks` ou par projet. + +### Shell interactif (REPL) + +LancĂ© par `am` sans sous-commande : + +- ComplĂ©tion Tab personnalisĂ©e (commandes, agents, alias, groupes, flags, shells, thĂšmes) +- Historique des commandes +- Passerelle systĂšme : commandes inconnues exĂ©cutĂ©es dans le shell actif +- Commandes internes : `ls`, `dir`, `cd`, `ps`, `where`, `get`, `shell`, `theme`, `exit`, `/help` +- BanniĂšre ASCII art + +--- + +## 📊 Dashboard TUI + +```mermaid +flowchart LR + DASH["am dashboard"] --> OVER["Overview"] + DASH --> ACT["ActivitĂ©"] + DASH --> STATS["Statistiques"] + DASH --> SESS["Sessions"] + DASH --> PROJ["Projets"] +``` + +- Navigation : `Tab` / `←` / `→` pour les onglets +- DĂ©filement : `j` / `k` ou flĂšches +- Quitter : `q` +- DonnĂ©es agrĂ©gĂ©es depuis `state.json`, le journal d'Ă©vĂ©nements, les sessions et les projets + +--- + +## đŸ§Ș Tests + +### Organisation + +- **Tests unitaires** : dans chaque module `src/*.rs` sous `#[cfg(test)]` +- **Tests d'intĂ©gration** : dans `tests/` + +### Fichiers de tests notables + +| Fichier | Couverture | +|---------|------------| +| `annotations_test.rs` | Favoris, notes, tags | +| `config_test.rs` | Chargement/validation/fusion | +| `dashboard_test.rs` | Dashboard | +| `doctor_test.rs` | `am doctor` | +| `dry_run_test.rs` | Mode `--dry-run` | +| `events_test.rs` | Journal d'Ă©vĂ©nements | +| `history_test.rs` | Historique | +| `process_test.rs` | Processus | +| `profiles_test.rs` | Profils | +| `projects_test.rs` | Projets | +| `sessions_test.rs` | Sessions | +| `stats_test.rs` | Statistiques | + +Lancer les tests : + +```bash +cargo test +``` + +--- + +## 📩 Packaging et distribution + +``` +dist/ Binaires prĂ©compilĂ©s +├── am-linux-x86_64 +├── am-linux-aarch64 +├── am-windows-x86_64.zip +├── am_0.4.2_amd64.deb +└── ... + +packaging/ Scripts de packaging +├── deb/ +├── rpm/ +├── homebrew/ +├── scoop/ +└── winget/ + +completions/ Scripts de complĂ©tion +├── _am zsh +├── _am.ps1 PowerShell +├── am.bash bash +├── am.elv elvish +└── am.fish fish + +man/ Pages de manuel gĂ©nĂ©rĂ©es +``` + +- `build.rs` enregistre le commit et la branche git pour `am version` +- `scripts/build-release.ps1` construit les releases multiplateformes +- `scripts/render-manifests.ps1` gĂ©nĂšre les manifests de packaging + +--- + +## đŸ—‚ïžïž Arborescence des donnĂ©es utilisateur + +### Windows + +``` +%LOCALAPPDATA%\agent-manager\ +├── agents\ installations +├── logs\ logs agents +└── state\ state.json, events-*.jsonl, probe-cache.json +``` + +### Linux + +``` +~/.local/share/agent-manager/ donnĂ©es +~/.local/state/agent-manager/ logs, state, events, cache +~/.config/agent-manager/ config.yaml +``` + +### macOS + +``` +~/Library/Application Support/agent-manager/ donnĂ©es +~/.local/state/agent-manager/ logs, state, events, cache +~/.config/agent-manager/ config.yaml +``` + +Variables d'environnement de dĂ©bogage/test : + +- `AGENT_MANAGER_DATA` : remplace le rĂ©pertoire de donnĂ©es +- `AGENT_MANAGER_STATE` : remplace le rĂ©pertoire d'Ă©tat + +--- + +## 🔐 SĂ©curitĂ© + +- Mode `--dry-run` universel pour simuler sans modifier +- Checksums `sha256` pour les binaires tĂ©lĂ©chargĂ©s +- Confirmation avant exĂ©cution des scripts d'installation +- Secrets dans le trousseau OS, jamais dans les logs ni le journal +- Installation isolĂ©e : aucune modification systĂšme + +--- + +## 🔁 RĂ©sumĂ© du flux de donnĂ©es + +```mermaid +flowchart LR + CONFIG["📄 YAML config"] --> CATALOG["📚 Catalog"] + CATALOG --> INSTALL["📩 Installateurs"] + INSTALL --> STATE["đŸ’Ÿ state.json"] + STATE --> PROCESS["⚙ Processus"] + PROCESS --> EVENTS["📝 Journal JSONL"] + EVENTS --> STATS["📊 Stats / Dashboard"] + EVENTS --> SESSIONS["📅 Sessions"] + EVENTS --> PROJECTS["đŸ—‚ïžïž Projets"] +``` + +--- + +## 📚 RĂ©fĂ©rences + +- `src/lib.rs:4` : documentation d'architecture du crate +- `src/cli.rs:24` : options globales +- `src/cli.rs:61` : Ă©numĂ©ration des commandes +- `src/app.rs:16` : struct `App` +- `src/config.rs:25` : struct `Config` +- `src/config.rs:148` : struct `AgentDef` +- `src/config.rs:241` : enum `InstallType` +- `src/state.rs:12` : struct `StateFile` +- `src/events.rs:57` : struct `Event` +- `README.md` : documentation utilisateur complĂšte + +--- + +*Document gĂ©nĂ©rĂ© pour agent-manager v0.4.2* 🚀 diff --git a/Cargo.lock b/Cargo.lock index c7b967d..9ccfe6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "chrono", @@ -32,8 +32,9 @@ dependencies = [ "crossterm", "flate2", "keyring", + "nu-ansi-term", "ratatui", - "rustyline", + "reedline", "semver", "serde", "serde_json", @@ -191,6 +192,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -272,12 +276,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "cfg_aliases" version = "0.2.2" @@ -368,15 +366,6 @@ dependencies = [ "roff", ] -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -471,6 +460,7 @@ dependencies = [ "mio", "parking_lot", "rustix", + "serde", "signal-hook", "signal-hook-mio", "winapi", @@ -632,12 +622,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" - [[package]] name = "euclid" version = "0.22.14" @@ -1036,6 +1020,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1174,7 +1167,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix 0.29.0", + "nix", "winapi", ] @@ -1227,18 +1220,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - [[package]] name = "nix" version = "0.29.0" @@ -1247,7 +1228,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.1", "cfg-if", - "cfg_aliases 0.2.2", + "cfg_aliases", "libc", "memoffset", ] @@ -1262,6 +1243,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1601,16 +1591,16 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", "serde", - "strum", + "strum 0.28.0", "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -1666,14 +1656,14 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", - "strum", + "strum 0.28.0", "time", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -1685,6 +1675,26 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "reedline" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826c1fc22a2b1f14c3f6a80fc3d56adfbfb913d07f170bce4246700de7adfd50" +dependencies = [ + "chrono", + "crossterm", + "fd-lock", + "itertools 0.13.0", + "nu-ansi-term", + "serde", + "strip-ansi-escapes", + "strum 0.27.2", + "thiserror 2.0.20", + "unicase", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "regex" version = "1.13.1" @@ -1797,26 +1807,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "rustyline" -version = "14.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "clipboard-win", - "fd-lock", - "libc", - "log", - "memchr", - "nix 0.28.0", - "unicode-segmentation", - "unicode-width 0.1.14", - "utf8parse", - "windows-sys 0.52.0", -] - [[package]] name = "ryu" version = "1.0.23" @@ -1992,19 +1982,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2155,7 +2175,7 @@ dependencies = [ "libc", "log", "memmem", - "nix 0.29.0", + "nix", "num-derive", "num-traits", "ordered-float", @@ -2262,6 +2282,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2280,17 +2306,11 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", ] -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.2" @@ -2366,6 +2386,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 91f5b58..b58dfc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.4.1" +version = "0.4.2" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" @@ -25,7 +25,8 @@ semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" -rustyline = { version = "14", default-features = false, features = ["with-file-history"] } +reedline = "0.49" +nu-ansi-term = "0.50" sha2 = "0.10" shell-words = "1" tar = "0.4" diff --git a/README.md b/README.md index 8faf063..99e4830 100644 --- a/README.md +++ b/README.md @@ -227,16 +227,21 @@ PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish). ❯ ls | where size > 1mb # filtre la derniĂšre table (>, <, >=, <=, ==, !=, =~) ❯ ps | where name =~ am # =~ cherche dans le texte ❯ ls | get name # sĂ©lectionne une colonne - ❯ cd # complĂ©tion des dossiers uniquement, un niveau par Tab + ❯ cd # complĂ©tion des dossiers — menu interactif façon Nushell ❯ shell # affiche le shell courant + les shells disponibles ❯ shell bash # change le shell de la session ❯ !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. +La complĂ©tion Tab est un **menu interactif façon Nushell** : une +correspondance unique est insĂ©rĂ©e directement ; plusieurs correspondances +ouvrent un menu sous la ligne avec le **premier choix en surbrillance**, et +chaque Tab dĂ©place la surbrillance vers le choix suivant (Shift+Tab vers +l'arriĂšre). **EntrĂ©e** valide le choix, **Échap** ferme le menu, les flĂšches +↑/↓ naviguent et continuer Ă  taper filtre la liste en direct. Le menu +affiche une **description** Ă  cĂŽtĂ© de chaque candidat (commandes et agents) +et suit la palette du thĂšme actif. Pour rendre le choix permanent : `settings.default_shell: pwsh` dans `config.yaml` (voir `am config path`). diff --git a/RELEASING.md b/RELEASING.md index 7e142f9..b9666e4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -56,7 +56,7 @@ doit signaler aucune dĂ©pendance manquante. Une fois toutes les archives dans `dist/` : - scripts/render-manifests.ps1 -Version 0.4.1 + scripts/render-manifests.ps1 -Version 0.4.2 Ce script rend, avec la version et les sha256 rĂ©els : @@ -67,16 +67,16 @@ Ce script rend, avec la version et les sha256 rĂ©els : - `dist/homebrew/am.rb` → Ă  servir via un tap, ou Ă  soumettre - `dist/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz dans ~/rpmbuild/SOURCES) -- Paquet deb : `packaging/deb/make-deb.sh 0.4.1` → dist/am_0.4.1_amd64.deb +- Paquet deb : `packaging/deb/make-deb.sh 0.4.2` → dist/am_0.4.2_amd64.deb ## 4. CrĂ©er la release sur Gitea -Via l'interface web : Releases > New Release, tag v0.4.1, attacher les +Via l'interface web : Releases > New Release, tag v0.4.2, attacher les archives. Ou via l'API (jeton Gitea requis) : curl -X POST -H "Authorization: token " \ -H "Content-Type: application/json" \ - -d '{"tag_name":"v0.4.1","name":"v0.4.1","body":"notes de version"}' \ + -d '{"tag_name":"v0.4.2","name":"v0.4.2","body":"notes de version"}' \ https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases curl -X POST -H "Authorization: token " \ @@ -89,7 +89,7 @@ archives. Ou via l'API (jeton Gitea requis) : ## 5. Tag git (dĂ©clenche aussi le pipeline CI) - git tag v0.4.1 && git push origin v0.4.1 + git tag v0.4.2 && git push origin v0.4.2 ## Conventions de nommage (attendues par les installateurs et self-update) diff --git a/man/am-self-update.1 b/man/am-self-update.1 index 249e983..2967658 100644 --- a/man/am-self-update.1 +++ b/man/am-self-update.1 @@ -2,11 +2,11 @@ .el .ds Aq ' .TH am-self-update 1 "self-update " .SH NAME -self\-update \- Update agent\-manager itself from GitHub Releases +self\-update \- Update agent\-manager itself from the latest release .SH SYNOPSIS \fBself\-update\fR [\fB\-\-check\fR] [\fB\-\-to\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION -Update agent\-manager itself from GitHub Releases +Update agent\-manager itself from the latest release .SH OPTIONS .TP \fB\-\-check\fR diff --git a/man/am.1 b/man/am.1 index 77a3a03..6c9c665 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 0.4.1" +.TH am 1 "am 0.4.2" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -163,7 +163,7 @@ am\-completion(1) Generate a shell completion script .TP am\-self\-update(1) -Update agent\-manager itself from GitHub Releases +Update agent\-manager itself from the latest release .TP am\-self\-uninstall(1) Remove agent\-manager and everything it created from this machine @@ -174,4 +174,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v0.4.1 +v0.4.2 diff --git a/scripts/render-manifests.ps1 b/scripts/render-manifests.ps1 index 1ad9334..6c3bc9f 100644 --- a/scripts/render-manifests.ps1 +++ b/scripts/render-manifests.ps1 @@ -1,6 +1,6 @@ # Renders the packaging templates (scoop, winget, homebrew, rpm) with the # version and the sha256 of every release archive. -# Usage: scripts/render-manifests.ps1 -Version 0.4.1 [-DistDir dist] +# Usage: scripts/render-manifests.ps1 -Version 0.4.2 [-DistDir dist] param( [Parameter(Mandatory = $true)][string]$Version, [string]$DistDir = "dist" diff --git a/src/dashboard.rs b/src/dashboard.rs index 961162c..9cafb1b 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -223,6 +223,9 @@ mod tests { p.probe_cache_file = dir.join("probe-cache.json"); p.config_dir = Some(dir.join("config")); app.paths = p; + // The state store keeps the path it was created with: repoint it at + // the sandbox too, or the test reads the developer's real state. + app.state = crate::state::StateStore::new(dir.join("state.json")); app } diff --git a/src/output.rs b/src/output.rs index 5f514ff..6bcd695 100644 --- a/src/output.rs +++ b/src/output.rs @@ -235,61 +235,6 @@ pub fn kv_table( out } -/// Build the lines of the boxed Tab-completion menu from (candidate, -/// description) rows. Single column when no descriptions exist. -pub fn completion_box(rows: &[(String, String)]) -> Vec { - let has_desc = rows.iter().any(|(_, d)| !d.is_empty()); - let name_w = rows - .iter() - .map(|(n, _)| n.chars().count()) - .max() - .unwrap_or(0) - .max("name".chars().count()); - let desc_w = if has_desc { - rows.iter() - .map(|(_, d)| truncate(d, 40).chars().count()) - .max() - .unwrap_or(0) - .max("description".chars().count()) - } else { - 0 - }; - let border = |left: char, mid: char, right: char| -> String { - let mut line = String::new(); - line.push(left); - line.push_str(&"─".repeat(name_w + 2)); - if has_desc { - line.push(mid); - line.push_str(&"─".repeat(desc_w + 2)); - } - line.push(right); - line - }; - let mut out = Vec::new(); - out.push(border('╭', '┬', '╼')); - if has_desc { - out.push(format!( - "│ {} │ {} │", - pad("name", name_w), - pad("description", desc_w) - )); - out.push(border('├', '┌', '─')); - } - for (name, desc) in rows { - if has_desc { - out.push(format!( - "│ {} │ {} │", - pad(name, name_w), - pad(&truncate(desc, 40), desc_w) - )); - } else { - out.push(format!("│ {} │", pad(name, name_w))); - } - } - out.push(border('╰', '┮', '╯')); - out -} - // --------------------------------------------------------------------------- // Tables // --------------------------------------------------------------------------- @@ -457,40 +402,6 @@ fn pad(s: &str, width: usize) -> String { mod tests { use super::*; - #[test] - fn completion_box_with_descriptions_is_aligned() { - let rows = vec![ - ("list".to_string(), "list installed agents".to_string()), - ("status".to_string(), "show agent state".to_string()), - ]; - let lines = completion_box(&rows); - assert_eq!(lines.len(), 6); // top, header, sep, 2 rows, bottom - assert!(lines[0].starts_with('╭') && lines[0].contains('┬')); - assert!(lines[0].ends_with('╼')); - assert!(lines[1].contains("name") && lines[1].contains("description")); - assert!(lines[2].starts_with('├')); - assert!(lines[5].starts_with('╰')); - let widths: Vec = lines.iter().map(|l| l.chars().count()).collect(); - assert!( - widths.iter().all(|w| *w == widths[0]), - "unaligned: {lines:?}" - ); - assert!(lines[3].contains("list") && lines[3].contains("list installed agents")); - } - - #[test] - fn completion_box_without_descriptions_is_single_column() { - let rows = vec![ - ("src".to_string(), "".to_string()), - ("am.exe".to_string(), "".to_string()), - ]; - let lines = completion_box(&rows); - assert_eq!(lines.len(), 4); // top, 2 rows, bottom - assert!(!lines.iter().any(|l| l.contains("description"))); - let widths: Vec = lines.iter().map(|l| l.chars().count()).collect(); - assert!(widths.iter().all(|w| *w == widths[0])); - } - #[test] fn kv_table_is_aligned_with_and_without_colors() { let rows = vec![ diff --git a/src/repl.rs b/src/repl.rs index 2db525a..5b8e73e 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -7,10 +7,13 @@ //! pass-through to pwsh, cmd, bash, zsh, ... The user's default shell is //! detected at startup and highlighted in the banner and the status line. //! -//! Uses rustyline for line editing: Tab completion (commands, agents, aliases, -//! groups, flags, shell names), command history persisted in the state -//! directory, and graceful fallback to plain line input when no terminal is -//! available. +//! Uses reedline (Nushell's line editor) for editing: Nushell-style Tab +//! completion — a single match is inserted immediately, several matches open +//! an interactive menu under the prompt with the first choice highlighted; +//! further Tabs move the highlight to the next choice (Enter validates, Esc +//! closes, arrows navigate, typing filters live). Command history is persisted +//! in the state directory, and there is a graceful fallback to plain line +//! input when no terminal is available. use crate::app::App; use crate::cli::{Command, StartArgs}; @@ -23,12 +26,13 @@ use crate::shell::ShellSession; use crate::theme::Theme; use anyhow::{anyhow, Result}; use colored::Colorize; -use rustyline::completion::{Completer, Pair}; -use rustyline::error::ReadlineError; -use rustyline::highlight::Highlighter; -use rustyline::hint::Hinter; -use rustyline::validate::Validator; -use rustyline::{Context, Helper}; +use nu_ansi_term::{Color, Style}; +use reedline::{ + default_emacs_keybindings, ColumnarMenu, Completer, Emacs, FileBackedHistory, KeyCode, + KeyModifiers, MenuBuilder, MenuTextStyle, Prompt, PromptEditMode, PromptHistorySearch, + Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion, +}; +use std::borrow::Cow; use std::collections::BTreeMap; use std::io::{IsTerminal, Write}; use std::path::PathBuf; @@ -55,9 +59,6 @@ pub struct AmCompleter { /// Short descriptions shown next to candidates in the Tab menu /// (Nushell-style). descriptions: BTreeMap, - /// Active color theme (used by the boxed menu highlight; follows the - /// app theme when it is switched with the REPL 'theme ' command). - theme: std::cell::Cell<&'static crate::theme::Theme>, } /// One-line descriptions for the Tab completion menu. @@ -142,15 +143,9 @@ impl AmCompleter { } completer.agents.sort(); completer.agents.dedup(); - completer.theme.set(app.theme()); completer } - /// Point the menu at a new theme (after 'theme ' in the REPL). - pub fn set_theme(&self, theme: &'static crate::theme::Theme) { - self.theme.set(theme); - } - pub fn from_catalog( catalog: &crate::catalog::Catalog, aliases: &BTreeMap, @@ -192,7 +187,6 @@ impl AmCompleter { descriptions.insert(a.name.clone(), truncate_desc(&desc)); } Self { - theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ "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", @@ -329,147 +323,186 @@ impl AmCompleter { } impl Completer for AmCompleter { - type Candidate = Pair; - - fn complete( - &self, - line: &str, - pos: usize, - _ctx: &Context<'_>, - ) -> rustyline::Result<(usize, Vec)> { + fn complete(&mut self, line: &str, pos: usize) -> Vec { let (start, cands) = self.candidates_for(line, pos); - 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() - } 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 { - 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 + cands .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, - } + .map(|c| Suggestion { + value: c.clone(), + description: self.descriptions.get(&c).cloned(), + span: Span::new(start, pos), + ..Default::default() }) .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; - } +// --------------------------------------------------------------------------- +// Line editor (reedline) and Nushell-style completion menu +// --------------------------------------------------------------------------- + +/// The REPL prompt, unchanged from the rustyline era. While a completion +/// menu is open, reedline replaces the (empty) indicator with the menu +/// marker "| ", like Nushell. +struct AmPrompt; + +impl Prompt for AmPrompt { + fn render_prompt_left(&self) -> Cow<'_, str> { + Cow::Borrowed("❯ ") } - prefix -} -impl Hinter for AmCompleter { - type Hint = String; - - fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { - None + fn render_prompt_right(&self) -> Cow<'_, str> { + Cow::Borrowed("") } -} -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>( + fn render_prompt_indicator(&self, _mode: PromptEditMode) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + Cow::Borrowed(" ") + } + + fn render_prompt_history_search_indicator( &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.get().frame(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.get().hdr(candidate)) - } else { - let bar = self.theme.get().frame("│"); - Cow::Owned(format!( - "{}{}{}{}{}", - bar, - self.theme.get().acc(cells[1]), - bar, - cells[2], - bar - )) - } - } else if cells.len() == 3 { - // Single-column menu (no descriptions, e.g. the theme - // names): paint the bars and the name. - let bar = self.theme.get().frame("│"); - Cow::Owned(format!( - "{}{}{}", - bar, - self.theme.get().acc(cells[1]), - bar - )) - } else { - Cow::Borrowed(candidate) + _search: PromptHistorySearch, + ) -> Cow<'_, str> { + Cow::Borrowed("(reverse-i-search)'") + } +} + +/// Map an ANSI basic color code (30-37, 90-97) onto the nu-ansi-term +/// named palette. +fn basic_color(n: u8) -> Option { + match n { + 30 => Some(Color::Black), + 31 => Some(Color::Red), + 32 => Some(Color::Green), + 33 => Some(Color::Yellow), + 34 => Some(Color::Blue), + 35 => Some(Color::Purple), + 36 => Some(Color::Cyan), + 37 => Some(Color::White), + 90 => Some(Color::DarkGray), + 91 => Some(Color::LightRed), + 92 => Some(Color::LightGreen), + 93 => Some(Color::LightYellow), + 94 => Some(Color::LightBlue), + 95 => Some(Color::LightPurple), + 96 => Some(Color::LightCyan), + 97 => Some(Color::LightGray), + _ => None, + } +} + +/// Map one theme SGR string ("1;38;5;81") onto a nu-ansi-term Style so the +/// completion menu can follow the active theme. Unknown parameters are +/// ignored; the empty string (mono theme) yields the default style. +fn sgr_to_style(sgr: &str) -> Style { + let toks: Vec<&str> = sgr.split(';').collect(); + let mut style = Style::default(); + let mut i = 0; + while i < toks.len() { + match toks[i] { + "1" => style = style.bold(), + "2" => style = style.dimmed(), + "3" => style = style.italic(), + "4" => style = style.underline(), + "7" => style = style.reverse(), + "38" if toks.get(i + 1) == Some(&"5") => { + if let Some(n) = toks.get(i + 2).and_then(|t| t.parse::().ok()) { + style = style.fg(Color::Fixed(n)); + } + i += 2; + } + "39" => style = style.fg(Color::Default), + other => { + if let Some(c) = other.parse::().ok().and_then(basic_color) { + style = style.fg(c); } } - _ => Cow::Borrowed(candidate), } + i += 1; + } + style +} + +/// Completion menu colors derived from the active theme: the highlighted +/// choice is the theme accent in reverse video (like Nushell's default), +/// the other choices are dimmed and the descriptions use the info color. +/// With the mono theme the accent is plain, so only the reverse-video +/// highlight remains. +fn menu_text_style(theme: &crate::theme::Theme, color: bool) -> MenuTextStyle { + let accent = if color { + sgr_to_style(theme.accent) + } else { + Style::default() + }; + MenuTextStyle { + selected_text_style: accent.reverse(), + text_style: if color { + sgr_to_style(theme.dim) + } else { + Style::default() + }, + description_style: if color { + sgr_to_style(theme.info) + } else { + Style::default() + }, + selected_match_style: accent.reverse().underline(), + match_style: Style::default().underline(), } } -impl Validator for AmCompleter {} -impl Helper for AmCompleter {} + +/// Build the reedline line editor: +/// +/// * Nushell-style Tab: the first press opens the completion menu with the +/// first choice highlighted; every further press moves the highlight to +/// the next choice (Enter validates, Esc closes, the arrow keys navigate +/// and typing filters the list live). Shift+Tab moves backwards. +/// * Quick completions: a single match is inserted immediately, without +/// opening the menu. +/// * Command history persisted in the state directory (history.txt). +/// * The completion menu styled with the active theme (the REPL 'theme +/// ' command rebuilds the editor so the menu follows). +fn build_line_editor(app: &App, history_path: &std::path::Path) -> Result { + let mut keybindings = default_emacs_keybindings(); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::MenuNext, + ]), + ); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::BackTab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::MenuPrevious, + ]), + ); + let history = FileBackedHistory::with_file(1000, history_path.to_path_buf()) + .map_err(|e| anyhow!("cannot open the command history: {e}"))?; + let color = app.color(); + let ts = menu_text_style(app.theme(), color); + let menu = ColumnarMenu::default() + .with_name("completion_menu") + .with_text_style(ts.text_style) + .with_selected_text_style(ts.selected_text_style) + .with_description_text_style(ts.description_style) + .with_selected_match_text_style(ts.selected_match_style) + .with_match_text_style(ts.match_style); + Ok(Reedline::create() + .with_completer(Box::new(AmCompleter::new(app))) + .with_quick_completions(true) + .with_history(Box::new(history)) + .with_menu(ReedlineMenu::EngineCompleter(Box::new(menu))) + .with_edit_mode(Box::new(Emacs::new(keybindings))) + .with_ansi_colors(color)) +} // --------------------------------------------------------------------------- // Banner (Hermes-style) @@ -998,54 +1031,71 @@ pub fn run(app: &App) -> Result { } fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result { - let mut rl: rustyline::Editor = - rustyline::Editor::new()?; - rl.set_helper(Some(AmCompleter::new(app))); - // Nushell-style completion: a single match is inserted immediately; - // otherwise Tab inserts the longest common prefix and the menu lists the - // candidates with their descriptions. - use rustyline::config::{CompletionType, Configurer}; - let _ = rl.set_completion_type(CompletionType::List); - let history_path = app.paths.state_file.with_file_name("history.txt"); - if rl.load_history(&history_path).is_err() { - // First run: no history yet. + // With piped stdin (tests, scripts, CI) the line editor cannot read + // console keys — reedline would block on the console instead of the + // pipe. Fall back to plain line input (run() handles the fallback). + if !std::io::stdin().is_terminal() { + return Err(anyhow!("stdin is not a terminal")); } + let history_path = app.paths.state_file.with_file_name("history.txt"); + let mut rl = build_line_editor(app, &history_path)?; + let prompt = AmPrompt; let started = Instant::now(); let mut last: Option = None; let mut last_duration: Option = None; + let mut first_read = true; + let mut theme: &'static crate::theme::Theme = app.theme(); loop { print_status(app, started, last_duration); - match rl.readline("❯ ") { - Ok(line) => { - let trimmed = line.trim().to_string(); - if trimmed.is_empty() { - continue; - } - let _ = rl.add_history_entry(trimmed.as_str()); - let t = Instant::now(); - let result = handle_input(app, session, &mut last, &trimmed); - last_duration = Some(t.elapsed()); - record_history(app, sid, &trimmed, &result, t.elapsed()); - // 'theme ' may have switched the app theme: repoint the - // completion menu at the new palette. - if let Some(h) = rl.helper_mut() { - h.set_theme(app.theme()); - } - match result { - Ok(true) => break, - Ok(false) => {} - Err(e) => app.log.error(&format!("{e:#}")), - } - } - Err(ReadlineError::Interrupted) => continue, - Err(ReadlineError::Eof) => break, + let signal = match rl.read_line(&prompt) { + Ok(signal) => signal, Err(e) => { + // No usable terminal (e.g. AM_FORCE_REPL with piped stdin): + // fall back to plain line input like before. + if first_read { + return Err(anyhow!("line editor unavailable: {e}")); + } app.log.warn(&format!("input error: {e}")); break; } + }; + first_read = false; + let line = match signal { + Signal::Success(line) => line, + // Like rustyline: Ctrl-C aborts the current line, Ctrl-D (EOF on + // an empty line) ends the session. + Signal::CtrlC => continue, + Signal::CtrlD => break, + Signal::HostCommand(_) | Signal::ExternalBreak(_) => continue, + // Signal is non-exhaustive: stay in the REPL for future signals. + _ => continue, + }; + let trimmed = line.trim().to_string(); + if trimmed.is_empty() { + continue; + } + let t = Instant::now(); + let result = handle_input(app, session, &mut last, &trimmed); + last_duration = Some(t.elapsed()); + record_history(app, sid, &trimmed, &result, t.elapsed()); + // 'theme ' 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()) { + theme = app.theme(); + let _ = rl.sync_history(); + match build_line_editor(app, &history_path) { + Ok(new_rl) => rl = new_rl, + Err(e) => app.log.warn(&format!("cannot rebuild the editor: {e:#}")), + } + } + match result { + Ok(true) => break, + Ok(false) => {} + Err(e) => app.log.error(&format!("{e:#}")), } } - let _ = rl.save_history(&history_path); + let _ = rl.sync_history(); Ok(0) } @@ -1181,7 +1231,10 @@ fn handle_line( } return Ok(false); } - let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?; + // Windows: split with backslashes as literal path separators (the Unix + // shell_words splitter would strip them from 'cd dev\git\...'). Unix + // keeps the classic backslash-escaping semantics. + let tokens = split_command(line)?; if tokens.is_empty() { return Ok(false); } @@ -1620,8 +1673,7 @@ fn eval_pipeline(app: &App, last: &mut Option, line: &str) -> Result< .collect(); let mut table: Option = None; for part in &parts { - let tokens = - shell_words::split(part).map_err(|e| anyhow!("cannot parse command: {e}"))?; + let tokens = split_command(part)?; if tokens.is_empty() { continue; } @@ -1681,6 +1733,52 @@ fn eval_pipeline(app: &App, last: &mut Option, line: &str) -> Result< Ok(false) } +/// Split a command line into tokens. +/// +/// On Windows, backslashes are literal path separators: 'cd dev\git\...' +/// must reach change_dir intact, and double quotes group tokens ("" escapes +/// a quote), like cmd/PowerShell. The Unix shell_words splitter would eat +/// every backslash as an escape character, breaking Windows paths. +/// On Unix, the classic backslash-escaping rules are kept. +fn split_command(line: &str) -> Result> { + #[cfg(windows)] + { + let mut tokens: Vec = Vec::new(); + let mut cur = String::new(); + let mut chars = line.chars().peekable(); + let mut in_quotes = false; + while let Some(c) = chars.next() { + match c { + '"' => { + if in_quotes && chars.peek() == Some(&'"') { + chars.next(); + cur.push('"'); + } else { + in_quotes = !in_quotes; + } + } + c if c.is_whitespace() && !in_quotes => { + if !cur.is_empty() { + tokens.push(std::mem::take(&mut cur)); + } + } + c => cur.push(c), + } + } + if in_quotes { + anyhow::bail!("unterminated quote"); + } + if !cur.is_empty() { + tokens.push(cur); + } + Ok(tokens) + } + #[cfg(not(windows))] + { + shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}")) + } +} + /// 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)); @@ -1977,18 +2075,79 @@ mod tests { } #[test] - fn completion_menu_paints_single_column_bars() { - let c = completer(); - c.set_theme(crate::theme::find("ocean").unwrap()); - let row = "│ ocean │"; - let out = c.highlight_candidate(row, rustyline::CompletionType::List); + fn completer_produces_suggestions_with_spans() { + // The reedline completer maps (start, candidates) onto suggestions + // whose span covers exactly the typed word. + let mut c = completer(); + let suggs = c.complete("inst", 5); + assert!(!suggs.is_empty()); + for s in &suggs { + assert_eq!(s.span.start, 0, "completes from the line start"); + assert_eq!(s.span.end, 5); + assert!(s.value.starts_with("inst"), "value: {}", s.value); + } + let suggs = c.complete("install cl", 11); + assert_eq!(suggs[0].span.start, 8); + assert_eq!(suggs[0].span.end, 11); + assert!(suggs.iter().all(|s| s.value.starts_with("cl"))); + } + + #[test] + fn completer_suggestions_carry_descriptions() { + let mut c = completer(); + let suggs = c.complete("star", 5); + assert!(suggs.iter().any(|s| { + s.value == "start" && s.description.as_deref() == Some("start an agent") + })); + // Path candidates (cd) have no description: the menu shows them in + // columnar layout instead of the single description column. The + // absolute path keeps the test independent from the process cwd. + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("src")).unwrap(); + let line = format!("cd {}", dir.path().display()); + let suggs = c.complete(&line, line.len()); + let sep = std::path::MAIN_SEPARATOR; + let expected = format!("{}{sep}", dir.path().display()); assert!( - out.contains("\x1b[38;5;75m"), - "bars not painted with the frame color: {out}" + suggs.iter().any(|s| s.value == expected), + "expected {expected}: {suggs:?}" ); + assert!(suggs.iter().all(|s| s.description.is_none())); + } + + #[test] + fn sgr_theme_strings_map_to_ansi_term_styles() { + // "1;38;5;81" = bold + 256-color 81 (ocean accent). + let s = sgr_to_style("1;38;5;81"); + assert!(s.is_bold, "accent should be bold"); + let fg = s.foreground.expect("accent should set a color"); + assert_eq!(fg, Color::Fixed(81)); + // Basic ANSI colors ("36" = cyan) map onto the named palette. + let s = sgr_to_style("36"); + assert_eq!(s.foreground, Some(Color::Cyan)); + // The mono theme uses empty SGR strings: default style, no color. + let s = sgr_to_style(""); + assert!(!s.is_bold); + assert_eq!(s.foreground, None); + } + + #[test] + fn menu_styles_follow_the_theme() { + let ocean = crate::theme::find("ocean").unwrap(); + let ts = menu_text_style(ocean, true); assert!( - out.contains("\x1b[1;38;5;81m"), - "name not painted with the accent color: {out}" + ts.selected_text_style.is_reverse, + "the highlighted choice must be in reverse video" + ); + assert_eq!( + ts.selected_text_style.foreground, + Some(Color::Fixed(81)), + "the highlighted choice uses the accent color" + ); + assert_eq!( + ts.description_style.foreground, + Some(Color::Fixed(117)), + "descriptions use the theme info color" ); } @@ -2051,4 +2210,35 @@ mod tests { assert!(s.ends_with("here")); assert!(s.chars().count() <= 9); } + + #[cfg(windows)] + #[test] + fn split_command_keeps_windows_backslashes() { + // The core regression: 'cd dev\git\...' must not lose its + // backslashes (they are path separators on Windows, not escapes). + let tokens = split_command("cd dev\\git\\Rust\\agent-manager\\").unwrap(); + assert_eq!(tokens, vec!["cd", "dev\\git\\Rust\\agent-manager\\"]); + let tokens = split_command("ls dev\\AI\\").unwrap(); + assert_eq!(tokens, vec!["ls", "dev\\AI\\"]); + } + + #[cfg(windows)] + #[test] + fn split_command_handles_quotes_and_whitespace() { + let tokens = split_command("cd \"my dir\"\\src").unwrap(); + assert_eq!(tokens, vec!["cd", "my dir\\src"]); + let tokens = split_command("echo \"a\"\"b\"").unwrap(); + assert_eq!(tokens, vec!["echo", "a\"b"]); + assert!(split_command("echo \"unterminated").is_err()); + assert!(split_command(" ").unwrap().is_empty()); + } + + #[cfg(not(windows))] + #[test] + fn split_command_keeps_unix_escaping() { + let tokens = split_command("ls foo\\ bar").unwrap(); + assert_eq!(tokens, vec!["ls", "foo bar"]); + let tokens = split_command("cd dev/git").unwrap(); + assert_eq!(tokens, vec!["cd", "dev/git"]); + } }