import React from "react"; import { render, type Instance } from "ink"; import type { AgentUI } from "@maia/core"; import { App } from "./components"; import { UIStore, type Choice } from "./store"; /** * Interface Ink (React pour CLI). Façade impérative au-dessus d'un UIStore : le CLI appelle des méthodes * (info, select, readInput…), l'application React se contente d'afficher l'état du store. * Ink n'est démarré qu'au premier affichage ; `close()` restaure le terminal. */ export class InkUI implements AgentUI { private readonly store = new UIStore(); private app: Instance | null = null; private start(): UIStore { // exitOnCtrlC: false : Ctrl+C est géré par les composants (annuler une saisie, interrompre un tour) if (!this.app) this.app = render(, { exitOnCtrlC: false }); return this.store; } /** Démonte l'interface (à appeler en fin de programme). */ async close(): Promise { const app = this.app; if (!app) return; this.app = null; await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages const exited = app.waitUntilExit(); // à créer AVANT unmount() app.unmount(); await exited; } // ----- messages ----- intro(title: string): void { this.start().log("intro", title); } outro(message: string): void { this.start().log("outro", message); } info(message: string): void { this.start().log("info", message); } warn(message: string): void { this.start().log("warn", message); } error(message: string): void { this.start().log("error", message); } /** Spinner pendant une opération asynchrone (chargement des modèles, etc.). */ async withSpinner(message: string, fn: () => Promise, done = "Terminé"): Promise { const store = this.start(); store.setBusy(message); try { const result = await fn(); store.setBusy(null); store.log("success", done); return result; } catch (err) { store.setBusy(null); store.log("error", `${message} — échec`); throw err; } } // ----- saisies ----- /** Retourne null si l'utilisateur annule (Ctrl+C). */ select(message: string, choices: Choice[], initialValue?: T): Promise { return this.start().select(message, choices, initialValue); } /** Retourne null si l'utilisateur annule (Ctrl+C / Ctrl+D). */ readInput(): Promise { return this.start().readInput(); } confirm(message: string): Promise { return this.start().confirm(message); } /** Ctrl+C pendant un tour de l'agent appelle ce gestionnaire (null : plus rien à interrompre). */ setInterruptHandler(handler: (() => void) | null): void { this.store.setInterruptHandler(handler); } /** Affiche un message utilisateur déjà saisi (relecture d'une session rechargée). */ showUser(text: string): void { this.start().log("user", text); } /** Affiche les règles de permissions par fichier (allow / ask / deny). */ showPermissions(files: PermissionFile[]): void { this.start().showPermissions(files); } /** Commande "!" de l'utilisateur et son résultat. */ onShell(command: string, output: string, status: string | null): void { this.start().shell(command, output, status); } /** Spinner personnalisé (null : le retirer). */ setBusy(label: string | null): void { this.start().setBusy(label); } // ----- AgentUI : déroulé de l'agent ----- onThinking(active: boolean): void { this.start().setBusy(active ? "En réflexion" : null); } onText(text: string): void { this.start().appendStreaming(text); } onTextEnd(): void { this.start().endStreaming(); } onToolCall(name: string, summary: string): void { this.start().toolCall(name, summary); } onToolResult(name: string, output: string, isError: boolean): void { this.start().toolResult(name, output, isError); } }