MagicCode/packages/ui/src/components.tsx
2026-10-29 01:10:36 +01:00

541 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// L'import de React reste nécessaire si un outil compile le JSX en mode « classique » (React.createElement)
import { appendFileSync } from "node:fs";
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,
MultiSelectPromptState,
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 (
<Text dimColor>
Aucun serveur MCP activé. Déclarez-les dans ~/.maia/mcp.json, puis autorisez-les dans ./.maia/settings.json : "allow": ["Mcp(nom)"].
</Text>
);
}
return (
<Box flexDirection="column">
{servers.map((s) => (
<Box key={s.name} flexDirection="column">
<Box>
<Box marginRight={1} flexShrink={0}>
<Badge color={s.status === "ok" ? "green" : "red"}>{s.status === "ok" ? "OK" : "FAIL"}</Badge>
</Box>
<Text>{s.name}</Text>
</Box>
{s.status === "fail" && s.error ? <Text dimColor>{` ${s.error}`}</Text> : null}
</Box>
))}
</Box>
);
}
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 <Text dimColor>Aucune règle de permission définie : chaque utilisation d'un outil demande une confirmation.</Text>;
}
return (
<Box flexDirection="column">
{files.map((file, i) => (
<Box key={file.path} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
<Text bold>{`${file.path} :`}</Text>
{PERMISSION_KINDS.filter((k) => file[k.key].length > 0).map((k) => (
<Box key={k.key} flexDirection="column">
<Badge color={k.color}>{k.label}</Badge>
{file[k.key].map((rule) => (
<Text key={rule}>{rule}</Text>
))}
</Box>
))}
</Box>
))}
</Box>
);
}
function LogLine({ item }: { item: LogItem }) {
switch (item.kind) {
case "intro":
return <Banner title={item.text} />;
case "outro":
return <Text dimColor>{item.text}</Text>;
case "info":
return (
<Box>
<Box marginRight={1} flexShrink={0}>
<Badge color="blue">INFO</Badge>
</Box>
<Text color={LIGHT_BLUE}>{item.text}</Text>
</Box>
);
case "success":
return (
<Text>
<Text color="green">✔ </Text>
{item.text}
</Text>
);
case "warn":
return (
<Box>
<Box marginRight={1} flexShrink={0}>
<Badge color="yellow">WARN</Badge>
</Box>
<Text>{item.text}</Text>
</Box>
);
case "error":
return (
<Box>
<Box marginRight={1} flexShrink={0}>
<Badge color="red">FAIL</Badge>
</Box>
<Text color={ERROR_TEXT}>{item.text}</Text>
</Box>
);
case "step":
return (
<Text>
<Text color="blue">● </Text>
{item.text}
</Text>
);
case "result":
return <Text dimColor>{item.text}</Text>;
case "choice":
return <Text dimColor>◆ {item.text}</Text>;
case "user":
return (
<Box>
<Text color="green" bold>
{"› "}
</Text>
<Text>{item.text}</Text>
</Box>
);
case "assistant":
// Réponse finale rendue en markdown (voir markdown.tsx) ; le texte en cours de streaming reste brut.
return <Markdown text={item.text} />;
case "permissions":
return <PermissionsView files={item.permissions ?? []} />;
case "mcp":
return <McpView servers={item.mcp ?? []} />;
case "shell":
return (
<Box flexDirection="column">
<Text>
<Text color={item.failed ? "red" : "magenta"} bold>
{"$ "}
</Text>
<Text bold>{item.text}</Text>
</Text>
{item.detail ? <Text dimColor>{item.detail}</Text> : null}
</Box>
);
}
}
// ───────────── 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 <Text color={WAIT_COLOR}>{`${frame} ${label}`}</Text>;
}
// ───────────── 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 (
<Box marginTop={1} borderStyle={DOTTED_BORDER} borderColor={color} borderLeft={false} borderRight={false} paddingX={1}>
{children}
</Box>
);
}
const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter";
/** Diagnostic des touches (MAIA_DEBUG_KEYS=1) : ajoute « input » et les drapeaux de `key` à ./.maia-keys.log. */
function logKey(input: string, key: object): void {
try {
const flags = Object.entries(key)
.filter(([, v]) => v === true)
.map(([k]) => k);
appendFileSync(".maia-keys.log", `${JSON.stringify({ input, flags })}\n`);
} catch {
/* diagnostic seulement */
}
}
/** Insère du texte au curseur. */
function insertText(s: EditState, text: string): EditState {
return {
value: s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor),
cursor: s.cursor + text.length,
hist: null,
draft: s.draft,
};
}
function InputPrompt({ prompt }: { prompt: InputPromptState }) {
const initial: EditState = { value: "", cursor: 0, hist: null, draft: "" };
const [view, setView] = useState<EditState>(initial);
// Source de vérité synchrone : plusieurs touches peuvent arriver avant le prochain rendu (collage, frappe rapide).
const ref = useRef<EditState>(initial);
const apply = (next: EditState) => {
ref.current = next;
setView(next);
};
useInput((input, key) => {
const s = ref.current;
if (process.env.MAIA_DEBUG_KEYS) logKey(input, key);
if (key.ctrl && input === "c") return prompt.resolve(null);
if (key.ctrl && input === "d" && s.value === "") return prompt.resolve(null);
// Maj+Entrée (ou Alt+Entrée) : saut de ligne dans le prompt, pas de validation. Ctrl+J arrive comme un « \n » brut
// et est inséré par le chemin de saisie normal plus bas.
// Certains terminaux envoient Maj+Entrée comme la séquence « ESC O M » (Entrée du pavé numérique) : Ink en retire
// l'ESC et livre le texte « OM » en un seul bloc (une frappe normale arrive toujours caractère par caractère).
if (input === "OM" || input === "\x1bOM") return apply(insertText(s, "\n"));
// Ctrl+J : saut de ligne brut, quelle que soit la façon dont Ink le qualifie.
if (input === "\n") return apply(insertText(s, "\n"));
if (key.return && (key.shift || key.meta)) return apply(insertText(s, "\n"));
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) });
// Début / fin du prompt (tout le texte saisi, même sur plusieurs lignes) : Origine / Fin, ou Ctrl+A / Ctrl+E
if (key.home || (key.ctrl && input === "a")) return apply({ ...s, cursor: 0 });
if (key.end || (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
apply(insertText(s, input.replace(/\r\n?/g, "\n")));
});
const frame = useSpinnerFrame(INPUT_SPINNER);
const { value, cursor } = view;
const at = value[cursor] ?? " ";
return (
<InputFrame color="greenBright">
<Text color={INPUT_COLOR} bold>
{`${frame} `}
</Text>
{value === "" ? (
<Text>
<Text inverse> </Text>
<Text dimColor>{PLACEHOLDER}</Text>
</Text>
) : (
<Text>
{value.slice(0, cursor)}
<Text inverse>{at === "\n" ? " " : at}</Text>
{at === "\n" ? "\n" : ""}
{value.slice(cursor + 1)}
</Text>
)}
</InputFrame>
);
}
/** Pendant le travail de l'agent : la zone de saisie reste là, grisée (la saisie reprend à la fin du tour). */
function WaitingInput() {
return (
<InputFrame color="gray">
<Text dimColor>{"… Traitement en cours — Ctrl+C : interrompre"}</Text>
</InputFrame>
);
}
// ───────────── 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 (
<Box flexDirection="column" marginTop={1}>
<Box>
<Box marginRight={1} flexShrink={0}>
<Badge color="green">ASK</Badge>
</Box>
<Text bold>{prompt.message}</Text>
</Box>
{visible.map((choice, i) => {
const selected = start + i === index;
return (
<Text key={start + i} color={selected ? "cyan" : undefined}>
{selected ? "❯ " : " "}
{choice.label}
{choice.hint ? <Text dimColor> {choice.hint}</Text> : null}
</Text>
);
})}
<Text dimColor>
↑/↓ choisir · Entrée valider · Échap annuler{n > SELECT_WINDOW ? ` · ${index + 1}/${n}` : ""}
</Text>
</Box>
);
}
// ───────────── Sélection multiple (/delete) ─────────────
function MultiSelectPrompt({ prompt }: { prompt: MultiSelectPromptState }) {
const n = prompt.choices.length;
const [view, setView] = useState<{ index: number; checked: ReadonlySet<number> }>({ index: 0, checked: new Set() });
// Source de vérité synchrone (plusieurs touches peuvent arriver avant le rendu suivant)
const ref = useRef(view);
const apply = (next: { index: number; checked: ReadonlySet<number> }) => {
ref.current = next;
setView(next);
};
useInput((input, key) => {
const s = ref.current;
if (key.upArrow || input === "k") return apply({ ...s, index: (s.index - 1 + n) % n });
if (key.downArrow || input === "j") return apply({ ...s, index: (s.index + 1) % n });
if (input === " ") {
// Espace : coche ou décoche la ligne courante
const checked = new Set(s.checked);
if (checked.has(s.index)) checked.delete(s.index);
else checked.add(s.index);
return apply({ ...s, checked });
}
// Suppr : valide la sélection (sans sélection, rien ne se passe)
if (key.delete) {
if (s.checked.size === 0) return;
return prompt.resolve([...s.checked].sort((a, b) => a - b).map((i) => prompt.choices[i]!.value));
}
if (key.escape || (key.ctrl && input === "c")) prompt.resolve(null);
});
const { index, checked } = view;
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 (
<Box flexDirection="column" marginTop={1}>
<Box>
<Box marginRight={1} flexShrink={0}>
<Badge color="green">ASK</Badge>
</Box>
<Text bold>{prompt.message}</Text>
</Box>
{visible.map((choice, i) => {
const at = start + i;
const current = at === index;
const on = checked.has(at);
return (
<Text key={at} color={current ? "cyan" : undefined}>
{current ? "❯ " : " "}
<Text color={on ? "red" : undefined}>{on ? "◉ " : "○ "}</Text>
{choice.label}
{choice.hint ? <Text dimColor> {choice.hint}</Text> : null}
</Text>
);
})}
<Text dimColor>
{`↑/↓ se déplacer · Espace cocher/décocher · ${prompt.actionLabel} · Échap annuler · ${checked.size} sélectionnée${checked.size > 1 ? "s" : ""}`}
{n > SELECT_WINDOW ? ` · ${index + 1}/${n}` : ""}
</Text>
</Box>
);
}
// ───────────── Confirmation ─────────────
function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
useInput((input, key) => {
if (input === "o" || input === "O" || input === "y" || input === "Y") prompt.resolve("yes");
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve("no");
else if (prompt.allowStop && (input === "s" || input === "S")) prompt.resolve("stop");
// 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 (
<InputFrame color="yellow">
<Box marginRight={1} flexShrink={0}>
<Badge color="green">ASK</Badge>
</Box>
<Text>
{prompt.message} <Text dimColor>{prompt.allowStop ? "(o/n/s=stop)" : "(o/n)"}</Text>
</Text>
</InputFrame>
);
}
function PromptView({ prompt, state }: { prompt: PromptState; state: UIState }) {
switch (prompt.kind) {
case "input":
return (
<Box flexDirection="column">
<InputPrompt prompt={prompt} />
</Box>
);
case "select":
return <SelectPrompt prompt={prompt} />;
case "multiselect":
return <MultiSelectPrompt prompt={prompt} />;
case "confirm":
return <ConfirmPrompt prompt={prompt} />;
}
}
// ───────────── 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) */}
<Static items={state.items}>
{(item) => (
<Box key={item.id} marginTop={1}>
<LogLine item={item} />
</Box>
)}
</Static>
{state.streaming ? (
<Box marginTop={1}>
<Text>{state.streaming}</Text>
</Box>
) : null}
{/* Ligne d'état réservée : toujours présente (vide hors traitement) pour que la zone de saisie ne bouge jamais */}
<Box marginTop={1} height={1}>
{state.busy ? <Spinner label={state.busy} /> : <Text> </Text>}
</Box>
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : state.closing ? null : <WaitingInput />}
{/* Toujours affichée, tout en bas : saisie, itérations, outils, confirmations, listes (taille maximale inconnue : tokens seuls) */}
<ContextBar used={state.contextUsed} max={state.contextMax} />
</>
);
}