// État de l'interface, sans dépendance à React ni à Ink : testable seul. // InkUI (impératif, utilisé par le CLI) modifie ce store ; les composants React s'y abonnent. export type LogKind = | "intro" | "outro" | "info" | "success" | "warn" | "error" | "step" | "result" | "choice" | "user" | "assistant" | "shell" | "permissions" | "mcp"; export interface LogItem { id: number; kind: LogKind; text: string; /** Lignes complémentaires (ex. sortie d'une commande shell). */ detail?: string; /** Commande shell en échec (affichée en rouge). */ failed?: boolean; /** Règles de permissions par fichier (kind "permissions"). */ permissions?: PermissionFile[]; /** Serveurs MCP activés et leur état (kind "mcp"). */ mcp?: McpServerView[]; } export interface McpServerView { name: string; status: "ok" | "fail"; /** Cause de l'échec (affichée en grisé sous le bandeau FAIL). */ error?: string; } export interface PermissionFile { path: string; allow: string[]; ask: string[]; deny: string[]; } export interface Choice { value: T; label: string; hint?: string; } export interface InputPromptState { kind: "input"; /** Saisies précédentes (les plus anciennes d'abord), pour la navigation ↑/↓. */ history: string[]; /** null = annulé (Ctrl+C / Ctrl+D). */ resolve(value: string | null): void; } export interface SelectPromptState { kind: "select"; message: string; choices: Choice[]; initialIndex: number; /** null = annulé. */ resolve(value: unknown): void; } /** Réponse à une demande d'autorisation (identique au type ConfirmAnswer du core). */ export type ConfirmAnswer = "yes" | "no" | "stop"; export interface ConfirmPromptState { kind: "confirm"; message: string; /** Propose « s » (stop : interrompre l'itération sans rien annuler). */ allowStop: boolean; resolve(value: ConfirmAnswer): void; } /** Sélection multiple (/delete) : Espace coche ou décoche, la touche d'action (Suppr) valide la sélection. */ export interface MultiSelectPromptState { kind: "multiselect"; message: string; choices: Choice[]; /** Libellé de la touche d'action dans l'aide, ex. « Suppr supprimer ». */ actionLabel: string; /** Valeurs cochées ; null = annulé. */ resolve(values: unknown[] | null): void; } export type PromptState = InputPromptState | SelectPromptState | MultiSelectPromptState | ConfirmPromptState; export interface UIState { /** Messages terminés, affichés une seule fois (historique du terminal). */ items: LogItem[]; /** Réponse du modèle en cours de streaming. */ streaming: string; /** Libellé du spinner, ou null. */ busy: string | null; prompt: PromptState | null; /** Tokens du contexte de la conversation (mis à jour après chaque réponse du LLM). */ contextUsed: number; /** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */ contextMax: number | null; /** Arrêt du programme en cours : la zone de saisie grisée n'est plus affichée. */ closing: boolean; } const RESULT_PREVIEW_MAX = 600; const SHELL_PREVIEW_MAX = 4000; export class UIStore { private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null, closing: false }; private listeners = new Set<() => void>(); private nextId = 1; private history: string[] = []; private interruptHandler: (() => void) | null = null; // ----- abonnement (useSyncExternalStore) ----- getSnapshot = (): UIState => this.state; subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; setClosing(): void { this.commit({ closing: true }); } private commit(patch: Partial): void { this.state = { ...this.state, ...patch }; for (const l of this.listeners) l(); } private withItem(kind: LogKind, text: string, extra: Partial = {}): LogItem[] { return [...this.state.items, { id: this.nextId++, kind, text, ...extra }]; } // ----- messages ----- log(kind: LogKind, text: string): void { this.commit({ items: this.withItem(kind, text) }); } appendStreaming(text: string): void { this.commit({ streaming: this.state.streaming + text }); } /** Fin d'une réponse : le texte streamé devient un message de l'historique. */ endStreaming(): void { const text = this.state.streaming.replace(/\n+$/, ""); if (!this.state.streaming) return; this.commit({ items: text ? this.withItem("assistant", text) : this.state.items, streaming: "" }); } /** Commande shell lancée par l'utilisateur ("!commande") et son résultat. */ shell(command: string, output: string, status: string | null): void { let body = output === "" ? "(aucune sortie)" : output; if (body.length > SHELL_PREVIEW_MAX) { const half = Math.floor(SHELL_PREVIEW_MAX / 2); body = `${body.slice(0, half)}\n… (${body.length - SHELL_PREVIEW_MAX} caractères omis à l'affichage) …\n${body.slice(-half)}`; } const detail = status ? `${body}\n[${status}]` : body; this.commit({ items: this.withItem("shell", command, { detail, failed: status !== null }) }); } /** Règles de permissions chargées, par fichier (commande /permissions). */ showPermissions(files: PermissionFile[]): void { this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) }); } /** Serveurs MCP activés et leur état (commande /mcp). */ showMcp(servers: McpServerView[]): void { this.commit({ items: this.withItem("mcp", "Serveurs MCP", { mcp: servers }) }); } /** Contexte actuel de la conversation, en tokens. */ setContextUsed(tokens: number): void { this.commit({ contextUsed: Math.max(0, Math.round(tokens)) }); } /** Taille maximale du contexte du modèle (null : inconnue, barre masquée). */ setContextMax(tokens: number | null): void { this.commit({ contextMax: tokens !== null && tokens > 0 ? Math.round(tokens) : null }); } setBusy(label: string | null): void { this.commit({ busy: label }); } toolCall(name: string, summary: string): void { this.log("step", `${name} ${summary}`); } toolResult(_name: string, output: string, isError: boolean): void { const preview = output.length > RESULT_PREVIEW_MAX ? `${output.slice(0, RESULT_PREVIEW_MAX)}\n… (tronqué)` : output; if (isError) this.log("error", preview); else if (preview) this.log("result", preview); } // ----- saisies : chaque appel renvoie une promesse résolue par le composant correspondant ----- readInput(): Promise { return new Promise((resolve) => { this.commit({ prompt: { kind: "input", history: [...this.history], resolve: (value) => { if (value) { this.history.push(value); this.commit({ prompt: null, items: this.withItem("user", value) }); } else { this.commit({ prompt: null }); } resolve(value); }, }, }); }); } select(message: string, choices: Choice[], initialValue?: T): Promise { if (choices.length === 0) return Promise.resolve(null); const found = initialValue === undefined ? -1 : choices.findIndex((c) => c.value === initialValue); return new Promise((resolve) => { this.commit({ prompt: { kind: "select", message, choices: choices as Choice[], initialIndex: Math.max(found, 0), resolve: (value) => { const chosen = value === null ? undefined : choices.find((c) => c.value === value); this.commit({ prompt: null, ...(chosen ? { items: this.withItem("choice", `${message} : ${chosen.label}`) } : {}), }); resolve(chosen ? chosen.value : null); }, }, }); }); } /** * Plusieurs choix : renvoie les valeurs cochées, dans l'ordre de la liste (tableau vide possible), ou null si annulé. * Liste vide : null sans invite. */ multiSelect(message: string, choices: Choice[], actionLabel: string): Promise { if (choices.length === 0) return Promise.resolve(null); return new Promise((resolve) => { this.commit({ prompt: { kind: "multiselect", message, choices: choices as Choice[], actionLabel, resolve: (values) => { this.commit({ prompt: null }); if (values === null) return resolve(null); resolve(choices.filter((c) => values.includes(c.value)).map((c) => c.value)); }, }, }); }); } /** Oui / non (et stop si `allowStop`). */ ask(message: string, allowStop = true): Promise { return new Promise((resolve) => { this.commit({ prompt: { kind: "confirm", message, allowStop, resolve: (value) => { this.commit({ prompt: null }); resolve(value); }, }, }); }); } /** Oui / non uniquement. */ async confirm(message: string): Promise { return (await this.ask(message, false)) === "yes"; } // ----- Ctrl+C pendant un tour de l'agent ----- setInterruptHandler(handler: (() => void) | null): void { this.interruptHandler = handler; } /** Retourne false s'il n'y a rien à interrompre. */ interrupt(): boolean { if (!this.interruptHandler) return false; this.interruptHandler(); return true; } }