// 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, PermissionFile, PromptState, SelectPromptState, UIState, UIStore, } from "./store"; // ───────────── Journal ───────────── 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 "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; 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)} )} ); } // ───────────── 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. }); return ( ASK {prompt.message} (o/n) ); } function PromptView({ prompt, state }: { prompt: PromptState; state: UIState }) { switch (prompt.kind) { case "input": return ( {state.contextMax !== null ? : null} ); 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} {state.busy ? ( ) : null} {state.prompt ? : null} ); }