541 lines
19 KiB
TypeScript
541 lines
19 KiB
TypeScript
// 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} />
|
||
</>
|
||
);
|
||
}
|