2026-10-11 22:41:17 +00:00
|
|
|
import React from "react";
|
|
|
|
|
import { render, type Instance } from "ink";
|
2026-10-14 23:12:15 +00:00
|
|
|
import type { AgentUI } from "@maia/core";
|
2026-10-11 22:41:17 +00:00
|
|
|
import { App } from "./components";
|
2026-10-26 22:17:20 +00:00
|
|
|
import { UIStore, type ConfirmAnswer, type Choice, type McpServerView, type PermissionFile } from "./store";
|
2026-10-11 22:41:17 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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)
|
2026-10-28 00:55:28 +00:00
|
|
|
if (!this.app) this.app = render(<App store={this.store} />, {
|
|
|
|
|
exitOnCtrlC: false,
|
|
|
|
|
// Protocole clavier « kitty » (négocié, ignoré si le terminal ne le gère pas) : distingue Maj+Entrée d'Entrée.
|
|
|
|
|
kittyKeyboard: { mode: "auto" },
|
|
|
|
|
});
|
2026-10-11 22:41:17 +00:00
|
|
|
return this.store;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Démonte l'interface (à appeler en fin de programme). */
|
|
|
|
|
async close(): Promise<void> {
|
|
|
|
|
const app = this.app;
|
|
|
|
|
if (!app) return;
|
|
|
|
|
this.app = null;
|
2026-10-20 22:00:52 +00:00
|
|
|
this.store.setClosing();
|
2026-10-11 22:41:17 +00:00
|
|
|
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<T>(message: string, fn: () => Promise<T>, done = "Terminé"): Promise<T> {
|
|
|
|
|
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<T>(message: string, choices: Choice<T>[], initialValue?: T): Promise<T | null> {
|
|
|
|
|
return this.start().select(message, choices, initialValue);
|
|
|
|
|
}
|
|
|
|
|
/** Retourne null si l'utilisateur annule (Ctrl+C / Ctrl+D). */
|
|
|
|
|
readInput(): Promise<string | null> {
|
|
|
|
|
return this.start().readInput();
|
|
|
|
|
}
|
2026-10-20 00:06:51 +00:00
|
|
|
/** Une seule question à la fois : les confirmations simultanées (outils en parallèle, sous-agents) passent l'une après l'autre. */
|
|
|
|
|
private confirmChain: Promise<unknown> = Promise.resolve();
|
2026-10-11 22:41:17 +00:00
|
|
|
confirm(message: string): Promise<boolean> {
|
2026-10-20 00:06:51 +00:00
|
|
|
const run = this.confirmChain.then(() => this.start().confirm(message));
|
|
|
|
|
this.confirmChain = run.catch(() => undefined);
|
|
|
|
|
return run;
|
2026-10-11 22:41:17 +00:00
|
|
|
}
|
2026-10-26 22:17:20 +00:00
|
|
|
/** Oui / non / stop (« stop » : interrompre l'itération sans rien annuler, pour donner d'autres informations). */
|
|
|
|
|
ask(message: string): Promise<ConfirmAnswer> {
|
|
|
|
|
const run = this.confirmChain.then(() => this.start().ask(message, true));
|
|
|
|
|
this.confirmChain = run.catch(() => undefined);
|
|
|
|
|
return run;
|
|
|
|
|
}
|
2026-10-11 22:41:17 +00:00
|
|
|
|
|
|
|
|
/** Ctrl+C pendant un tour de l'agent appelle ce gestionnaire (null : plus rien à interrompre). */
|
|
|
|
|
setInterruptHandler(handler: (() => void) | null): void {
|
|
|
|
|
this.store.setInterruptHandler(handler);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 23:23:59 +00:00
|
|
|
/** Affiche un message utilisateur déjà saisi (relecture d'une session rechargée). */
|
|
|
|
|
showUser(text: string): void {
|
|
|
|
|
this.start().log("user", text);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:49:58 +00:00
|
|
|
/** Affiche les règles de permissions par fichier (allow / ask / deny). */
|
|
|
|
|
showPermissions(files: PermissionFile[]): void {
|
|
|
|
|
this.start().showPermissions(files);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 23:54:32 +00:00
|
|
|
/** Serveurs MCP activés : nom et état (OK en vert, FAIL en rouge). */
|
|
|
|
|
showMcp(servers: McpServerView[]): void {
|
|
|
|
|
this.start().showMcp(servers);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 07:16:26 +00:00
|
|
|
/** 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:41:17 +00:00
|
|
|
// ----- AgentUI : déroulé de l'agent -----
|
|
|
|
|
onThinking(active: boolean): void {
|
2026-10-12 23:23:59 +00:00
|
|
|
this.start().setBusy(active ? "En réflexion" : null);
|
2026-10-11 22:41:17 +00:00
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-10-18 23:02:44 +00:00
|
|
|
/** Outils exécutés en parallèle : le spinner indique ce qui tourne et combien ont terminé. */
|
|
|
|
|
onToolsRunning(state: { names: string[]; done: number } | null): void {
|
|
|
|
|
if (!state) return this.start().setBusy(null);
|
|
|
|
|
const total = state.names.length;
|
|
|
|
|
const names = [...new Set(state.names)].join(", ");
|
|
|
|
|
this.start().setBusy(`Outils en parallèle : ${state.done}/${total} terminés (${names})`);
|
|
|
|
|
}
|
2026-10-15 22:54:08 +00:00
|
|
|
/** Taille du contexte (tokens) après chaque réponse du LLM : met à jour la barre de progression. */
|
2026-10-19 23:45:23 +00:00
|
|
|
onContextUsage(tokens: number): void {
|
|
|
|
|
this.start().setContextUsed(tokens);
|
2026-10-15 22:54:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Taille maximale du contexte du modèle (null : inconnue, la barre est masquée). */
|
|
|
|
|
setContextMax(tokens: number | null): void {
|
|
|
|
|
this.start().setContextMax(tokens);
|
|
|
|
|
}
|
2026-10-11 22:41:17 +00:00
|
|
|
}
|