> ## Content Index
> Fetch the complete content index at: https://www.sfeir.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Le Commis de Course : donnez vie à vos CLI avec Quarkus
- URL: https://www.sfeir.dev/back/donnez-vie-a-vos-cli-avec-quarkus/
- Published: 2026-08-19T09:45:14.000Z
- Updated: 2026-08-19T11:35:33.000Z
- Description: Le commis rend toujours son rapport instantanément. Dans la vraie vie, certaines courses prennent du temps, et le tavernier mérite de savoir où il en est. C'est pourquoi il donne vie à ses CLI avec Quarkus !
- Author: Erwan Le Tutour
- Tags: Back, Java, quarkus, CLI

## Le problème d'un commis trop rapide

[Dans l'épisode précédent](https://www.sfeir.dev/back/le-commis-de-course-creer-une-cli-interactive-avec-quarkus/), le commis rendait chacun de ses rapports en une fraction de seconde : lister des dettes, préparer une commande, purger un grimoire, tout ça tient dans quelques lignes exécutées instantanément. Confortable pour l'exemple, mais peu réaliste. Un vrai commis de course ne revient pas du marché en un clin d'œil : il faut le temps du trajet, puis celui de charger la carriole.

Sans un minimum de mise en scène, le tavernier se retrouve face à un terminal figé, sans savoir si le commis travaille encore ou s'est perdu en chemin. C'est ce qu'on répare ici, avec deux composants pensés pour deux situations différentes : une attente dont on ignore la durée, et un avancement qu'on peut mesurer.

## Une dépendance de moins, deux classes de plus

Avant de se lancer, une question s'impose : est-ce que ce genre de composant existe déjà quelque part, tout prêt à l'emploi ?

Du côté de Picocli, la réponse est non. Sa panoplie couvre le parsing des commandes, l'aide générée automatiquement, la coloration ANSI et l'autocomplétion, mais rien du côté des indicateurs de progression. C'est même une demande restée sans suite côté mainteneurs, qui renvoie plutôt vers des bibliothèques tierces spécialisées dans ce genre d'affichage.

On aurait pu ajouter l'une de ces bibliothèques. On a préféré écrire plusieurs petites classes maison, dans un nouveau package `affichage`, pour des besoins aussi ciblés : garder le projet à zéro dépendance supplémentaire, et surtout garder la main sur le vocabulaire et le rythme de l'affichage, pour rester dans l'esprit de la taverne plutôt que d'hériter du style par défaut d'une bibliothèque générique.

## Le sablier, pour une attente dont on ignore la durée

Le premier composant tourne pendant qu'une tâche de fond travaille, sans savoir combien de temps elle va prendre. C'est le cas typique d'un aller au marché : on sait qu'il faut y aller, on ne sait pas exactement combien de temps ça va prendre.

```java
public class Sablier {

    private static final char[] TRAME = {'|', '/', '-', '\\'};

    private final Terminal terminal;

    public Sablier(Terminal terminal) {
        this.terminal = terminal;
    }

    public <T> T tourner(String message, Callable<T> tache) throws Exception {
        ExecutorService executeur = Executors.newSingleThreadExecutor();
        Future<T> futur = executeur.submit(tache);
        PrintWriter ecran = terminal.writer();

        int indice = 0;
        try {
            while (!futur.isDone()) {
                ecran.print("\r" + message + " " + TRAME[indice++ % TRAME.length]);
                ecran.flush();
                Thread.sleep(120);
            }
        } finally {
            executeur.shutdown();
        }

        ecran.print("\r" + " ".repeat(message.length() + 2) + "\r");
        ecran.flush();
        return futur.get();
    }
}
```

La tâche s'exécute sur un fil séparé (`ExecutorService`), pendant que le fil principal se contente de réafficher le message et le caractère suivant de la trame, toutes les 120 millisecondes, jusqu'à ce que la tâche se termine.   
Le `\r` en tête de chaque affichage ramène le curseur en début de ligne, pour que le sablier tourne sur place plutôt que d'empiler des lignes à l'infini dans le terminal.

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/07/image-18.png)

Notre sablier en action

## La jauge de tonneau, pour un avancement mesurable

Le second composant convient mieux quand on connaît l'avancement au fur et à mesure, comme le remplissage d'une carriole pourcentage par pourcentage.

```java
public class JaugeDeTonneau {

    private static final int LARGEUR = 24;

    private final Terminal terminal;
    private final String message;

    public JaugeDeTonneau(Terminal terminal, String message) {
        this.terminal = terminal;
        this.message = message;
    }

    public void avancerA(int pourcentage) {
        PrintWriter ecran = terminal.writer();
        ecran.print("\r" + construireLigne(message, pourcentage));
        ecran.flush();
    }

    public void terminer() {
        terminal.writer().println();
        terminal.writer().flush();
    }

    static String construireLigne(String message, int pourcentage) {
        int borne = Math.max(0, Math.min(100, pourcentage));
        int rempli = (borne * LARGEUR) / 100;
        String barre = "=".repeat(rempli) + " ".repeat(LARGEUR - rempli);
        return String.format("%s [%s] %3d%%", message, barre, borne);
    }
}
```

Le détail qui compte ici, c'est `construireLigne` : une méthode statique pure, qui ne touche à aucun terminal, aucun flux, rien.   
Elle prend un message et un pourcentage, elle rend une chaîne. Autrement dit, on peut vérifier le rendu de la jauge sans avoir besoin d'un vrai terminal ni de simuler quoi que ce soit :

```java
@Test
void laJaugeEstPleineACentPourcent() {
    // When
    String ligne = JaugeDeTonneau.construireLigne("Chargement", 100);

    // Then
    assertTrue(ligne.contains("100%"));
    assertTrue(ligne.contains("[" + "=".repeat(24) + "]"));
}
```

Un composant d'affichage qui se teste sans jamais ouvrir un terminal, voilà qui n'allait pas forcément de soi.

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/07/image-19.png)

La jauge se remplit

## Une mission qui a besoin d'attendre

Reste à donner au commis une mission qui justifie ces deux outils. C'est le rôle de `voyage-marche` : le commis part, on suit son trajet au sablier, puis le chargement de sa carriole à la jauge.

```java
@Command(
        name = "voyage-marche",
        description = "Envoie le commis au marche chercher une carriole de provisions."
)
public class VoyageMarcheCommand implements Callable<Integer> {

    @Inject
    Terminal terminal;

    @Override
    public Integer call() throws Exception {
        Sablier sablier = new Sablier(terminal);
        sablier.tourner("Le commis trotte jusqu'au marche...", () -> {
            Thread.sleep(1500);
            return null;
        });

        JaugeDeTonneau jauge = new JaugeDeTonneau(terminal, "Chargement de la carriole");
        for (int pourcentage = 0; pourcentage <= 100; pourcentage += 4) {
            jauge.avancerA(pourcentage);
            Thread.sleep(60);
        }
        jauge.terminer();

        System.out.println("La carriole est chargee. Retour a la taverne.");
        return 0;
    }
}
```

Un détail à ne pas manquer : `Terminal terminal` est injecté ici exactement comme `RegistreDettesService` l'était dans la mission `exporter-dettes` de l'épisode précédent.   
Ça n'a rien d'automatique. Jusque-là, le terminal était créé et refermé localement, à l'intérieur de la boucle principale, invisible pour le reste de l'application.   
Pour qu'une mission puisse s'en servir, il a fallu le transformer en véritable bean CDI, partagé par toute la taverne plutôt que gardé dans la poche de la boucle interactive.

```java
@ApplicationScoped
public class TerminalProducer {

    @Produces
    @ApplicationScoped
    public Terminal creerTerminal() throws IOException {
        return TerminalBuilder.builder().system(true).build();
    }

    public void fermerTerminal(@Disposes Terminal terminal) throws IOException {
        terminal.close();
    }
}
```

La lanterne est allumée une bonne fois pour toutes au démarrage (`@Produces`), et refermée proprement à l'arrêt de l'application (`@Disposes`), plutôt que d'être rallumée à chaque course.   
N'importe quelle mission qui a besoin d'afficher quelque chose peut désormais se la faire prêter par simple injection, sans que la boucle principale n'ait à la lui transmettre explicitement.

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/07/image-20.png)

## Ce que ça change, et ce que ça ne change pas encore

Le commis dispose maintenant de deux façons de tenir le tavernier informé pendant qu'il travaille, plutôt qu'un silence total suivi d'un rapport soudain. Mais il faut être honnête sur une limite de cet épisode : le "travail" simulé ici n'est qu'un `Thread.sleep`. 

Le sablier tourne pendant une pause, la jauge avance sur un compteur qu'on incrémente nous-mêmes. Le jour où une vraie tâche de fond (un appel réseau, une lecture de fichier volumineux) remplacera ce sommeil simulé, ces deux composants seront prêts à l'accueillir sans changer une ligne : c'est tout l'intérêt de les avoir isolés du reste de la logique métier.

## Le grand registre : un vrai tableau plutôt que des colonnes à la main

Le rapport d'`exporter-dettes`, jusqu'ici, tenait dans un `printf` unique avec des largeurs de colonnes écrites en dur (`%-12s`, `%8.2f`). 

Ça fonctionne tant que les noms des aventuriers restent courts, et ça casse dès que Boromir-le-Longiligne rejoint la guilde. Le grand registre calcule les largeurs à partir du contenu réel, colonne par colonne :

```java
public class GrandRegistre {

    private final List<String> entetes;
    private final List<List<String>> lignes = new ArrayList<>();

    public GrandRegistre(List<String> entetes) {
        this.entetes = entetes;
    }

    public void ajouterLigne(List<String> valeurs) {
        if (valeurs.size() != entetes.size()) {
            throw new IllegalArgumentException("Le nombre de valeurs ne correspond pas au nombre d'entetes.");
        }
        lignes.add(valeurs);
    }

    public String construireEntete() {
        int[] largeurs = calculerLargeurs();
        String separateur = construireSeparateur(largeurs);
        return separateur + System.lineSeparator()
                + construireLigne(entetes, largeurs) + System.lineSeparator()
                + separateur;
    }

    public List<String> construireLignes() {
        int[] largeurs = calculerLargeurs();
        List<String> resultat = new ArrayList<>();
        for (List<String> ligne : lignes) {
            resultat.add(construireLigne(ligne, largeurs));
        }
        return resultat;
    }

    public String construirePied() {
        return construireSeparateur(calculerLargeurs());
    }
}
```

Un choix de conception mérite d'être signalé : le rendu est découpé en trois morceaux (`construireEntete`, `construireLignes`, `construirePied`) plutôt qu'en une seule grosse chaîne.   
Ça semble plus verbeux à l'usage, mais ça permet à l'appelant d'intervenir ligne par ligne entre l'en-tête et le pied, ce qui devient utile dès qu'on veut colorer certaines lignes plutôt que d'autres.

## Le sceau du tavernier : colorer sans casser l'alignement

Jusqu'ici, une dette critique se signalait par un simple suffixe textuel, `[CRITIQUE]`, ajouté en bout de ligne. Fonctionnel, mais un peu terne. La couleur ANSI fait beaucoup mieux comprendre, d'un coup d'œil, ce qui mérite l'attention du tavernier :

```java
public final class SceauDuTavernier {

    private SceauDuTavernier() {
    }

    public static String alerte(Terminal terminal, String texte) {
        return colorer(terminal, texte, AttributedStyle.RED);
    }

    public static String succes(Terminal terminal, String texte) {
        return colorer(terminal, texte, AttributedStyle.GREEN);
    }

    public static String avertissement(Terminal terminal, String texte) {
        return colorer(terminal, texte, AttributedStyle.YELLOW);
    }

    private static String colorer(Terminal terminal, String texte, int couleur) {
        AttributedStyle style = AttributedStyle.DEFAULT.foreground(couleur).bold();
        return new AttributedString(texte, style).toAnsi(terminal);
    }
}
```

Un point technique à ne pas manquer : la coloration passe par `AttributedString.toAnsi(terminal)`, avec le terminal en paramètre, plutôt que par des codes ANSI écrits en dur dans une chaîne. C'est ce qui permet à JLine de dégrader proprement l'affichage sur un terminal qui ne supporte pas les couleurs (un `DumbTerminal`, par exemple), au lieu d'afficher des séquences d'échappement brutes et illisibles à l'écran.

Un deuxième point, plus subtil : on ne colore jamais une cellule à l'intérieur du tableau, seulement une ligne entière, déjà entièrement construite par le grand registre.   
Les codes ANSI comptent dans la longueur d'une chaîne Java sans compter dans sa largeur affichée à l'écran ; les appliquer avant le calcul des largeurs de colonnes aurait faussé tout l'alignement. En colorant après coup, sur une ligne déjà figée, ce risque disparaît complètement :

```java
List<String> lignes = registre.construireLignes();
for (int index = 0; index < dettes.size(); index++) {
    String ligne = lignes.get(index);
    if (dettes.get(index).estCritique()) {
        System.out.println(SceauDuTavernier.alerte(terminal, ligne));
    } else {
        System.out.println(ligne);
    }
}
```

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/07/image-21.png)

## La demande de confirmation : donner un sens à la purge

La mission `purger-grimoire`, jusqu'ici, n'était qu'une coquille : elle affichait un message et s'arrêtait là, purge réelle ou simulation confondues. Une vraie purge de grimoire, pourtant, ne se rattrape pas. Elle mérite qu'on demande une dernière fois au tavernier s'il est bien sûr de son geste :

```java
public class DemandeConfirmation {

    private final LineReader lecteur;

    public DemandeConfirmation(Terminal terminal) {
        this.lecteur = LineReaderBuilder.builder().terminal(terminal).build();
    }

    public boolean confirmer(String question) {
        String reponse = lecteur.readLine(question + " (oui/non) : ").trim().toLowerCase();
        return reponse.equals("oui") || reponse.equals("o");
    }
}
```

Ce composant construit son propre `LineReader`, distinct de celui de la boucle principale. Ça peut surprendre au premier regard, mais ça reste sans danger : le `LineReader` de la boucle n'est pas sollicité pendant qu'une mission s'exécute (elle a déjà reçu la main), donc les deux ne se marchent jamais dessus. 

Rien n'empêcherait, pour une version plus aboutie, de partager un seul `LineReader` via CDI comme on l'a fait pour le `Terminal`, mais pour un simple oui/non ponctuel, un lecteur jetable reste largement suffisant.

Voilà ce que ça change dans la mission :

```java
@Override
public Integer call() {
    if (!simulation) {
        DemandeConfirmation confirmation = new DemandeConfirmation(terminal);
        boolean accepte = confirmation.confirmer(
                "Purger definitivement '" + nomGrimoire + "' ? Cette action est irreversible.");

        if (!accepte) {
            System.out.println("Purge annulee. Le grimoire reste intact.");
            return 0;
        }
    }

    String mode = simulation ? "simulation" : "purge effective";
    System.out.printf("Grimoire '%s' traite en mode %s.%n", nomGrimoire, mode);
    return 0;
}
```

Le mode `--simulation` garde tout son sens : il court-circuite la confirmation, puisqu'aucune action réelle n'est en jeu. La confirmation, elle, ne concerne que le geste qui compte vraiment.

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/07/image-22.png)

## Conclusion

Le commis ne se contente plus d'exécuter des commandes : il sait désormais montrer qu'il travaille, signaler ce qui compte et demander confirmation avant les actions irréversibles.

Sablier, jauge, registre et sceau transforment ainsi une simple ligne de commande en véritable interface interactive.

Le commis est prêt. Il ne lui manque plus qu'une vraie course.

---

Comme toujours, si vous souhaitez regarder le code de plus près, c'est par ici 👇

[GitHub - ErwanLT/quarkus-demo: Demo project for quarkus possibilityDemo project for quarkus possibility. Contribute to ErwanLT/quarkus-demo development by creating an account on GitHub.![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/icon/favicon-a97ce6b7-7f21-494b-bad9-6534868bb7d3.svg)GitHubErwanLT![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/thumbnail/5635256a-85fe-4dc6-8c3a-7e4df754b269-a54b479e-3f12-482b-968b-34ed53ecebd5)](https://github.com/ErwanLT/quarkus-demo?ref=sfeir.dev)