MagicCode/packages/ui/src/store.ts
2026-10-29 01:10:36 +01:00

305 lines
9.5 KiB
TypeScript

// É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<T = unknown> {
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<UIState>): void {
this.state = { ...this.state, ...patch };
for (const l of this.listeners) l();
}
private withItem(kind: LogKind, text: string, extra: Partial<LogItem> = {}): 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<string | null> {
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<T>(message: string, choices: Choice<T>[], initialValue?: T): Promise<T | null> {
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<T>(message: string, choices: Choice<T>[], actionLabel: string): Promise<T[] | null> {
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<ConfirmAnswer> {
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<boolean> {
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;
}
}