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"]);
+ }
}