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"
|
2026-10-12 07:16:26 +00:00
|
|
|
| "assistant"
|
2026-10-15 21:49:58 +00:00
|
|
|
| "shell"
|
2026-10-21 23:54:32 +00:00
|
|
|
| "permissions"
|
|
|
|
|
| "mcp";
|
2026-10-11 22:41:17 +00:00
|
|
|
|
|
|
|
|
export interface LogItem {
|
|
|
|
|
id: number;
|
|
|
|
|
kind: LogKind;
|
|
|
|
|
text: string;
|
2026-10-12 07:16:26 +00:00
|
|
|
/** 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[];
|
2026-10-21 23:54:32 +00:00
|
|
|
/** 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;
|
2026-10-15 21:49:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 22:17:20 +00:00
|
|
|
/** Réponse à une demande d'autorisation (identique au type ConfirmAnswer du core). */
|
|
|
|
|
export type ConfirmAnswer = "yes" | "no" | "stop";
|
|
|
|
|
|
2026-10-11 22:41:17 +00:00
|
|
|
export interface ConfirmPromptState {
|
|
|
|
|
kind: "confirm";
|
|
|
|
|
message: string;
|
2026-10-26 22:17:20 +00:00
|
|
|
/** Propose « s » (stop : interrompre l'itération sans rien annuler). */
|
|
|
|
|
allowStop: boolean;
|
|
|
|
|
resolve(value: ConfirmAnswer): void;
|
2026-10-11 22:41:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-10-15 22:54:08 +00:00
|
|
|
/** 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;
|
2026-10-20 22:00:52 +00:00
|
|
|
/** Arrêt du programme en cours : la zone de saisie grisée n'est plus affichée. */
|
|
|
|
|
closing: boolean;
|
2026-10-11 22:41:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RESULT_PREVIEW_MAX = 600;
|
2026-10-12 07:16:26 +00:00
|
|
|
const SHELL_PREVIEW_MAX = 4000;
|
2026-10-11 22:41:17 +00:00
|
|
|
|
|
|
|
|
export class UIStore {
|
2026-10-20 22:00:52 +00:00
|
|
|
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null, closing: false };
|
2026-10-11 22:41:17 +00:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-20 22:00:52 +00:00
|
|
|
setClosing(): void {
|
|
|
|
|
this.commit({ closing: true });
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:41:17 +00:00
|
|
|
private commit(patch: Partial<UIState>): void {
|
|
|
|
|
this.state = { ...this.state, ...patch };
|
|
|
|
|
for (const l of this.listeners) l();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 07:16:26 +00:00
|
|
|
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: "" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 07:16:26 +00:00
|
|
|
/** 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-21 23:54:32 +00:00
|
|
|
/** Serveurs MCP activés et leur état (commande /mcp). */
|
|
|
|
|
showMcp(servers: McpServerView[]): void {
|
|
|
|
|
this.commit({ items: this.withItem("mcp", "Serveurs MCP", { mcp: servers }) });
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 22:54:08 +00:00
|
|
|
/** Contexte actuel de la conversation, en tokens. */
|
2026-10-19 23:45:23 +00:00
|
|
|
setContextUsed(tokens: number): void {
|
|
|
|
|
this.commit({ contextUsed: Math.max(0, Math.round(tokens)) });
|
2026-10-15 22:54:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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 });
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 22:17:20 +00:00
|
|
|
/** Oui / non (et stop si `allowStop`). */
|
|
|
|
|
ask(message: string, allowStop = true): Promise<ConfirmAnswer> {
|
2026-10-11 22:41:17 +00:00
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
this.commit({
|
|
|
|
|
prompt: {
|
|
|
|
|
kind: "confirm",
|
|
|
|
|
message,
|
2026-10-26 22:17:20 +00:00
|
|
|
allowStop,
|
2026-10-11 22:41:17 +00:00
|
|
|
resolve: (value) => {
|
|
|
|
|
this.commit({ prompt: null });
|
|
|
|
|
resolve(value);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 22:17:20 +00:00
|
|
|
/** Oui / non uniquement. */
|
|
|
|
|
async confirm(message: string): Promise<boolean> {
|
|
|
|
|
return (await this.ask(message, false)) === "yes";
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:41:17 +00:00
|
|
|
// ----- 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;
|
|
|
|
|
}
|
|
|
|
|
}
|