// L'import de React reste nécessaire si un outil compile le JSX en mode « classique » (React.createElement)
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
import cliSpinners from "cli-spinners";
import { Badge } from "@inkjs/ui";
import { Box, Static, Text, useInput } from "ink";
import { Banner } from "./banner";
import { ERROR_TEXT, LIGHT_BLUE } from "./colors";
import { ContextBar } from "./context-bar";
import { Markdown } from "./markdown";
import type {
ConfirmPromptState,
InputPromptState,
LogItem,
McpServerView,
PermissionFile,
PromptState,
SelectPromptState,
UIState,
UIStore,
} from "./store";
// ───────────── Journal ─────────────
/** Serveurs MCP activés : bandeau OK (vert) ou FAIL (rouge) suivi du nom. */
function McpView({ servers }: { servers: McpServerView[] }) {
if (servers.length === 0) {
return (
Aucun serveur MCP activé. Déclarez-les dans ~/.maia/mcp.json, puis autorisez-les dans ./.maia/settings.json : "allow": ["Mcp(nom)"].
);
}
return (
{servers.map((s) => (
{s.status === "ok" ? "OK" : "FAIL"}
{s.name}
{s.status === "fail" && s.error ? {` ${s.error}`} : null}
))}
);
}
const PERMISSION_KINDS = [
{ key: "allow", label: "ALLOW", color: "green" },
{ key: "ask", label: "ASK", color: "yellow" },
{ key: "deny", label: "DENY", color: "red" },
] as const;
/** Règles par fichier : chemin, puis un badge ALLOW / ASK / DENY suivi des règles du type (une par ligne). */
function PermissionsView({ files }: { files: PermissionFile[] }) {
if (files.length === 0) {
return Aucune règle de permission définie : chaque utilisation d'un outil demande une confirmation.;
}
return (
{files.map((file, i) => (
{`${file.path} :`}
{PERMISSION_KINDS.filter((k) => file[k.key].length > 0).map((k) => (
{k.label}
{file[k.key].map((rule) => (
{rule}
))}
))}
))}
);
}
function LogLine({ item }: { item: LogItem }) {
switch (item.kind) {
case "intro":
return ;
case "outro":
return {item.text};
case "info":
return (
INFO
{item.text}
);
case "success":
return (
✔
{item.text}
);
case "warn":
return (
WARN
{item.text}
);
case "error":
return (
FAIL
{item.text}
);
case "step":
return (
●
{item.text}
);
case "result":
return {item.text};
case "choice":
return ◆ {item.text};
case "user":
return (
{"› "}
{item.text}
);
case "assistant":
// Réponse finale rendue en markdown (voir markdown.tsx) ; le texte en cours de streaming reste brut.
return ;
case "permissions":
return ;
case "mcp":
return ;
case "shell":
return (
{"$ "}
{item.text}
{item.detail ? {item.detail} : null}
);
}
}
// ───────────── Spinners (cli-spinners) ─────────────
interface SpinnerDef {
frames: string[];
interval: number;
}
/** Marqueur animé à gauche de la zone de saisie. */
const INPUT_SPINNER = cliSpinners.arrow3;
const INPUT_COLOR = "greenBright";
/** Attente de la réponse (« En réflexion »), chargements, exécution d'une commande. */
const WAIT_SPINNER = cliSpinners.dots10;
const WAIT_COLOR = "yellowBright";
/** Image courante de l'animation (change toutes les `interval` ms). */
function useSpinnerFrame(spinner: SpinnerDef): string {
const [index, setIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => setIndex((i) => (i + 1) % spinner.frames.length), spinner.interval);
return () => clearInterval(timer);
}, [spinner]);
return spinner.frames[index] ?? "";
}
/** Spinner à gauche du libellé, libellé et spinner dans la même couleur. */
function Spinner({ label }: { label: string }) {
const frame = useSpinnerFrame(WAIT_SPINNER);
return {`${frame} ${label}`};
}
// ───────────── Saisie de texte ─────────────
interface EditState {
value: string;
cursor: number;
/** Position dans l'historique pendant la navigation ↑/↓, sinon null. */
hist: number | null;
/** Texte en cours de frappe avant d'entrer dans l'historique. */
draft: string;
}
/** Bordure en pointillés (seuls les traits haut et bas sont affichés : voir borderLeft/borderRight). */
const DOTTED_BORDER = {
topLeft: "┈",
top: "┈",
topRight: "┈",
left: "┊",
bottomLeft: "┈",
bottom: "┈",
bottomRight: "┈",
right: "┊",
} as const;
/** Zone de saisie (cadre en pointillés) : toujours visible ; sert aussi aux demandes d'autorisation et à l'attente. */
function InputFrame({ color, children }: { color: string; children: React.ReactNode }) {
return (
{children}
);
}
const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter";
function InputPrompt({ prompt }: { prompt: InputPromptState }) {
const initial: EditState = { value: "", cursor: 0, hist: null, draft: "" };
const [view, setView] = useState(initial);
// Source de vérité synchrone : plusieurs touches peuvent arriver avant le prochain rendu (collage, frappe rapide).
const ref = useRef(initial);
const apply = (next: EditState) => {
ref.current = next;
setView(next);
};
useInput((input, key) => {
const s = ref.current;
if (key.ctrl && input === "c") return prompt.resolve(null);
if (key.ctrl && input === "d" && s.value === "") return prompt.resolve(null);
if (key.return) return prompt.resolve(s.value.trim());
if (key.leftArrow) return apply({ ...s, cursor: Math.max(0, s.cursor - 1) });
if (key.rightArrow) return apply({ ...s, cursor: Math.min(s.value.length, s.cursor + 1) });
if (key.ctrl && input === "a") return apply({ ...s, cursor: 0 });
if (key.ctrl && input === "e") return apply({ ...s, cursor: s.value.length });
if (key.ctrl && input === "u") return apply({ ...s, value: s.value.slice(s.cursor), cursor: 0, hist: null });
if (key.ctrl && input === "w") {
const before = s.value.slice(0, s.cursor).replace(/\S+\s*$/, "");
return apply({ ...s, value: before + s.value.slice(s.cursor), cursor: before.length, hist: null });
}
if (key.upArrow) {
if (prompt.history.length === 0) return;
const hist = s.hist === null ? prompt.history.length - 1 : Math.max(0, s.hist - 1);
const value = prompt.history[hist] ?? "";
return apply({ value, cursor: value.length, hist, draft: s.hist === null ? s.value : s.draft });
}
if (key.downArrow) {
if (s.hist === null) return;
if (s.hist >= prompt.history.length - 1) {
return apply({ value: s.draft, cursor: s.draft.length, hist: null, draft: "" });
}
const hist = s.hist + 1;
const value = prompt.history[hist] ?? "";
return apply({ ...s, value, cursor: value.length, hist });
}
if (key.backspace || key.delete) {
if (s.cursor === 0) return;
return apply({
...s,
value: s.value.slice(0, s.cursor - 1) + s.value.slice(s.cursor),
cursor: s.cursor - 1,
hist: null,
});
}
if (key.ctrl || key.meta || key.tab || key.escape || !input) return;
// Collage : une seule chaîne, éventuellement multi-lignes
const text = input.replace(/\r\n?/g, "\n");
apply({
value: s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor),
cursor: s.cursor + text.length,
hist: null,
draft: s.draft,
});
});
const frame = useSpinnerFrame(INPUT_SPINNER);
const { value, cursor } = view;
const at = value[cursor] ?? " ";
return (
{`${frame} `}
{value === "" ? (
{PLACEHOLDER}
) : (
{value.slice(0, cursor)}
{at === "\n" ? " " : at}
{at === "\n" ? "\n" : ""}
{value.slice(cursor + 1)}
)}
);
}
/** Pendant le travail de l'agent : la zone de saisie reste là, grisée (la saisie reprend à la fin du tour). */
function WaitingInput() {
return (
{"… Traitement en cours — Ctrl+C : interrompre"}
);
}
// ───────────── Liste de choix ─────────────
const SELECT_WINDOW = 10;
function SelectPrompt({ prompt }: { prompt: SelectPromptState }) {
const n = prompt.choices.length;
const [index, setIndex] = useState(prompt.initialIndex);
const indexRef = useRef(prompt.initialIndex);
const move = (next: number) => {
indexRef.current = next;
setIndex(next);
};
useInput((input, key) => {
if (key.upArrow || input === "k") move((indexRef.current - 1 + n) % n);
else if (key.downArrow || input === "j") move((indexRef.current + 1) % n);
else if (key.return) prompt.resolve(prompt.choices[indexRef.current]?.value ?? null);
else if (key.escape || (key.ctrl && input === "c")) prompt.resolve(null);
});
const start = Math.min(Math.max(0, index - Math.floor(SELECT_WINDOW / 2)), Math.max(0, n - SELECT_WINDOW));
const visible = prompt.choices.slice(start, start + SELECT_WINDOW);
return (
ASK
{prompt.message}
{visible.map((choice, i) => {
const selected = start + i === index;
return (
{selected ? "❯ " : " "}
{choice.label}
{choice.hint ? {choice.hint} : null}
);
})}
↑/↓ choisir · Entrée valider · Échap annuler{n > SELECT_WINDOW ? ` · ${index + 1}/${n}` : ""}
);
}
// ───────────── Confirmation ─────────────
function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
useInput((input, key) => {
if (input === "o" || input === "O" || input === "y" || input === "Y") prompt.resolve(true);
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false);
// Entrée seule est ignorée : une action sensible exige une réponse explicite.
});
// La demande d'autorisation prend la place de la zone de saisie
return (
ASK
{prompt.message} (o/n)
);
}
function PromptView({ prompt, state }: { prompt: PromptState; state: UIState }) {
switch (prompt.kind) {
case "input":
return (
);
case "select":
return ;
case "confirm":
return ;
}
}
// ───────────── Application ─────────────
export function App({ store }: { store: UIStore }) {
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
// Hors saisie, Ctrl+C interrompt le tour en cours (ou quitte s'il n'y en a pas)
useInput(
(input, key) => {
if (key.ctrl && input === "c" && !store.interrupt()) process.exit(130);
},
{ isActive: state.prompt === null },
);
return (
<>
{/* Une ligne vide avant chaque message (plutôt qu'après : la marge finale d'un élément Static peut être perdue) */}
{(item) => (
)}
{state.streaming ? (
{state.streaming}
) : null}
{/* Ligne d'état réservée : toujours présente (vide hors traitement) pour que la zone de saisie ne bouge jamais */}
{state.busy ? : }
{state.prompt ? : state.closing ? null : }
{/* Toujours affichée, tout en bas : saisie, itérations, outils, confirmations, listes (taille maximale inconnue : tokens seuls) */}
>
);
}