- Exécution de commandes shell inline avec `!commande` ou `!`commande args``
- Support dans les prompts directs et les fichiers .claude/commands/**/*.md
- Exécution parallèle de multiples commandes
- Gestion robuste des erreurs et timeouts
- Intégration avec les variables d'environnement de settings.json
Nouveaux fichiers:
- packages/cli/src/user-shell.ts
- packages/core/src/shell.ts et shell.test.ts
- packages/ext-claudecode/src/tools/todowrite.test.ts et webfetch.test.ts
- packages/ui/src/banner.tsx, pixels.ts et pixels.test.ts
Tests: ✅ Tous les tests passent
Breaking Changes: Mineur - SlashCommand.prompt() async
114 lines
4.3 KiB
TypeScript
114 lines
4.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { UIStore } from "./store";
|
|
|
|
const kinds = (s: UIStore) => s.getSnapshot().items.map((i) => `${i.kind}:${i.text}`);
|
|
|
|
describe("UIStore", () => {
|
|
it("le texte streamé devient un message à la fin de la réponse", () => {
|
|
const s = new UIStore();
|
|
s.appendStreaming("Bon");
|
|
s.appendStreaming("jour\n");
|
|
expect(s.getSnapshot().streaming).toBe("Bonjour\n");
|
|
s.endStreaming();
|
|
expect(s.getSnapshot().streaming).toBe("");
|
|
expect(kinds(s)).toEqual(["assistant:Bonjour"]);
|
|
});
|
|
|
|
it("notifie les abonnés et fournit un instantané stable entre deux changements", () => {
|
|
const s = new UIStore();
|
|
let calls = 0;
|
|
const off = s.subscribe(() => calls++);
|
|
const before = s.getSnapshot();
|
|
expect(s.getSnapshot()).toBe(before);
|
|
s.log("info", "a");
|
|
expect(calls).toBe(1);
|
|
expect(s.getSnapshot()).not.toBe(before);
|
|
off();
|
|
s.log("info", "b");
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("readInput : la saisie est journalisée, annulée = null, l'historique est transmis", async () => {
|
|
const s = new UIStore();
|
|
|
|
const first = s.readInput();
|
|
const p1 = s.getSnapshot().prompt;
|
|
expect(p1?.kind).toBe("input");
|
|
(p1 as { resolve(v: string | null): void }).resolve("bonjour");
|
|
expect(await first).toBe("bonjour");
|
|
expect(s.getSnapshot().prompt).toBeNull();
|
|
expect(kinds(s)).toEqual(["user:bonjour"]);
|
|
|
|
const second = s.readInput();
|
|
const p2 = s.getSnapshot().prompt as { history: string[]; resolve(v: string | null): void };
|
|
expect(p2.history).toEqual(["bonjour"]);
|
|
p2.resolve(null);
|
|
expect(await second).toBeNull();
|
|
expect(kinds(s)).toEqual(["user:bonjour"]); // rien de journalisé à l'annulation
|
|
});
|
|
|
|
it("select : présélection, valeur renvoyée, choix journalisé", async () => {
|
|
const s = new UIStore();
|
|
const choices = [
|
|
{ value: "a", label: "Alpha" },
|
|
{ value: "b", label: "Bravo" },
|
|
];
|
|
const result = s.select("Modèle", choices, "b");
|
|
const prompt = s.getSnapshot().prompt as { kind: string; initialIndex: number; resolve(v: unknown): void };
|
|
expect(prompt.kind).toBe("select");
|
|
expect(prompt.initialIndex).toBe(1);
|
|
prompt.resolve("a");
|
|
expect(await result).toBe("a");
|
|
expect(kinds(s)).toEqual(["choice:Modèle : Alpha"]);
|
|
});
|
|
|
|
it("select : annulation = null, liste vide = null sans invite", async () => {
|
|
const s = new UIStore();
|
|
const cancelled = s.select("Modèle", [{ value: 1, label: "un" }]);
|
|
(s.getSnapshot().prompt as { resolve(v: unknown): void }).resolve(null);
|
|
expect(await cancelled).toBeNull();
|
|
expect(await s.select("Vide", [])).toBeNull();
|
|
expect(s.getSnapshot().prompt).toBeNull();
|
|
});
|
|
|
|
it("confirm", async () => {
|
|
const s = new UIStore();
|
|
const answer = s.confirm("Autoriser Bash ?");
|
|
(s.getSnapshot().prompt as { resolve(v: boolean): void }).resolve(false);
|
|
expect(await answer).toBe(false);
|
|
expect(s.getSnapshot().prompt).toBeNull();
|
|
});
|
|
|
|
it("résultats d'outils : tronqués, erreurs distinguées, vides ignorés", () => {
|
|
const s = new UIStore();
|
|
s.toolCall("Read", "a.ts");
|
|
s.toolResult("Read", "x".repeat(1000), false);
|
|
s.toolResult("Bash", "", false);
|
|
s.toolResult("Bash", "échec", true);
|
|
const items = s.getSnapshot().items;
|
|
expect(items.map((i) => i.kind)).toEqual(["step", "result", "error"]);
|
|
expect(items[1]?.text.endsWith("… (tronqué)")).toBe(true);
|
|
});
|
|
|
|
it("interrupt : false sans gestionnaire, sinon l'appelle", () => {
|
|
const s = new UIStore();
|
|
expect(s.interrupt()).toBe(false);
|
|
let called = 0;
|
|
s.setInterruptHandler(() => called++);
|
|
expect(s.interrupt()).toBe(true);
|
|
expect(called).toBe(1);
|
|
s.setInterruptHandler(null);
|
|
expect(s.interrupt()).toBe(false);
|
|
});
|
|
|
|
it("commande shell : détail, échec signalé, longue sortie tronquée", () => {
|
|
const s = new UIStore();
|
|
s.shell("ls", "a\nb", null);
|
|
s.shell("false", "", "code de sortie : 1");
|
|
s.shell("cat gros", "x".repeat(10_000), null);
|
|
const [ok, ko, big] = s.getSnapshot().items;
|
|
expect(ok).toMatchObject({ kind: "shell", text: "ls", detail: "a\nb", failed: false });
|
|
expect(ko).toMatchObject({ detail: "(aucune sortie)\n[code de sortie : 1]", failed: true });
|
|
expect(big?.detail).toContain("caractères omis à l'affichage");
|
|
});
|
|
});
|