193 lines
5.3 KiB
TypeScript
193 lines
5.3 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";
|
||
|
|
|
||
|
|
export interface LogItem {
|
||
|
|
id: number;
|
||
|
|
kind: LogKind;
|
||
|
|
text: 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
|
||
|
|
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): LogItem[] {
|
||
|
|
return [...this.state.items, { id: this.nextId++, kind, text }];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ----- 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: "" });
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|