MagicCode/packages/ui/src/store.ts

225 lines
6.6 KiB
TypeScript
Raw Normal View History

2026-10-11 22:41:17 +00:00
// É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"
2026-10-15 21:49:58 +00:00
| "shell"
| "permissions";
2026-10-11 22:41:17 +00:00
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;
2026-10-15 21:49:58 +00:00
/** Règles de permissions par fichier (kind "permissions"). */
permissions?: PermissionFile[];
}
export interface PermissionFile {
path: string;
allow: string[];
ask: string[];
deny: string[];
2026-10-11 22:41:17 +00:00
}
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;
}
export interface ConfirmPromptState {
kind: "confirm";
message: string;
resolve(value: boolean): void;
}
export type PromptState = InputPromptState | SelectPromptState | 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;
}
const RESULT_PREVIEW_MAX = 600;
const SHELL_PREVIEW_MAX = 4000;
2026-10-11 22:41:17 +00:00
export class UIStore {
private state: UIState = { items: [], streaming: "", busy: null, prompt: null };
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);
};
};
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 }];
2026-10-11 22:41:17 +00:00
}
// ----- 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 }) });
}
2026-10-15 21:49:58 +00:00
/** Règles de permissions chargées, par fichier (commande /permissions). */
showPermissions(files: PermissionFile[]): void {
this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) });
}
2026-10-11 22:41:17 +00:00
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);
},
},
});
});
}
confirm(message: string): Promise<boolean> {
return new Promise((resolve) => {
this.commit({
prompt: {
kind: "confirm",
message,
resolve: (value) => {
this.commit({ prompt: null });
resolve(value);
},
},
});
});
}
// ----- 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;
}
}