MagicCode/packages/ui/src/components.tsx

397 lines
12 KiB
TypeScript
Raw Normal View History

2026-10-11 22:41:17 +00:00
// 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";
2026-10-12 23:23:59 +00:00
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";
2026-10-11 22:41:17 +00:00
import type {
ConfirmPromptState,
InputPromptState,
LogItem,
2026-10-15 21:49:58 +00:00
PermissionFile,
2026-10-11 22:41:17 +00:00
PromptState,
SelectPromptState,
UIStore,
} from "./store";
// ───────────── Journal ─────────────
2026-10-15 21:49:58 +00:00
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>
);
}
2026-10-11 22:41:17 +00:00
function LogLine({ item }: { item: LogItem }) {
switch (item.kind) {
case "intro":
return <Banner title={item.text} />;
2026-10-11 22:41:17 +00:00
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>
);
2026-10-11 22:41:17 +00:00
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>
2026-10-11 22:41:17 +00:00
);
case "error":
return (
<Box>
<Box marginRight={1} flexShrink={0}>
<Badge color="red">FAIL</Badge>
</Box>
<Text color={ERROR_TEXT}>{item.text}</Text>
</Box>
2026-10-11 22:41:17 +00:00
);
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":
return <Text>{item.text}</Text>;
2026-10-15 21:49:58 +00:00
case "permissions":
return <PermissionsView files={item.permissions ?? []} />;
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>
);
2026-10-11 22:41:17 +00:00
}
}
2026-10-12 23:23:59 +00:00
// ───────────── Spinners (cli-spinners) ─────────────
2026-10-11 22:41:17 +00:00
2026-10-12 23:23:59 +00:00
interface SpinnerDef {
frames: string[];
interval: number;
}
2026-10-11 22:41:17 +00:00
2026-10-12 23:23:59 +00:00
/** 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);
2026-10-11 22:41:17 +00:00
useEffect(() => {
2026-10-12 23:23:59 +00:00
const timer = setInterval(() => setIndex((i) => (i + 1) % spinner.frames.length), spinner.interval);
2026-10-11 22:41:17 +00:00
return () => clearInterval(timer);
2026-10-12 23:23:59 +00:00
}, [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>;
2026-10-11 22:41:17 +00:00
}
// ───────────── 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;
}
2026-10-12 23:23:59 +00:00
/** 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;
2026-10-11 22:41:17 +00:00
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<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 (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,
});
});
2026-10-12 23:23:59 +00:00
const frame = useSpinnerFrame(INPUT_SPINNER);
2026-10-11 22:41:17 +00:00
const { value, cursor } = view;
const at = value[cursor] ?? " ";
return (
2026-10-12 23:23:59 +00:00
<Box
marginTop={1}
borderStyle={DOTTED_BORDER}
borderColor="greenBright"
borderLeft={false}
borderRight={false}
paddingX={1}
>
<Text color={INPUT_COLOR} bold>
{`${frame} `}
2026-10-11 22:41:17 +00:00
</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>
)}
</Box>
);
}
// ───────────── 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>
2026-10-11 22:41:17 +00:00
<Text bold>{prompt.message}</Text>
</Box>
2026-10-11 22:41:17 +00:00
{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>
);
}
// ───────────── 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.
});
return (
<Box marginTop={1}>
<Box marginRight={1} flexShrink={0}>
<Badge color="green">ASK</Badge>
</Box>
2026-10-11 22:41:17 +00:00
<Text>
{prompt.message} <Text dimColor>(o/n)</Text>
</Text>
</Box>
);
}
function PromptView({ prompt }: { prompt: PromptState }) {
switch (prompt.kind) {
case "input":
return <InputPrompt prompt={prompt} />;
case "select":
return <SelectPrompt 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}
{state.busy ? (
<Box marginTop={1}>
<Spinner label={state.busy} />
</Box>
) : null}
{state.prompt ? <PromptView prompt={state.prompt} /> : null}
</>
);
}