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);
}
/** 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);
}
}