diff --git a/.gitignore b/.gitignore
index abfbf3d..2be012b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,3 @@ dist
*.log
.env
.maia
-generated_page.md
diff --git a/README.md b/README.md
index 90182d8..4fffa52 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ Le CLI garde une façade impérative (`InkUI` : `info`, `select`, `readInput`, `
ce qui rend la logique testable (`store.test.ts`). Les composants (saisie, liste, confirmation, spinner) sont écrits à la main ; seuls les badges viennent de [`@inkjs/ui`](https://github.com/vadimdemedes/ink-ui).
Un terminal interactif (TTY) est requis.
-Présentation : la bannière d'accueil reprend le style « MAIA » : masque pixel-art à gauche (demi-blocs ▀▄█, 20 × 20 pixels), titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 46 colonnes pour la version complète ; entre 25 et 45 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
+Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
une ligne vide sépare chaque message.
Messages du journal, avec le composant `Badge` de `@inkjs/ui` :
@@ -80,8 +80,14 @@ Messages du journal, avec le composant `Badge` de `@inkjs/ui` :
| `FAIL` (rouge) | erreur | `#DC8ADD` (`ERROR_TEXT`) |
| `ASK` (vert) | question posée à l'utilisateur (liste de choix, confirmation o/n), à gauche de la question | gras |
+Les réponses de l'agent sont rendues en markdown (titres, listes, code, tableaux, gras…) avec [`markdansi`](https://github.com/steipete/markdansi). Tout est isolé dans `packages/ui/src/markdown.tsx` : pour le retirer, supprimer ce fichier, la dépendance `markdansi` et remplacer `` par `` dans `components.tsx`. Le texte en cours de streaming reste brut ; la mise en forme apparaît quand la réponse est terminée. `MAIA_NO_MARKDOWN=1` désactive le rendu sans toucher au code, et un échec de `markdansi` retombe sur le texte brut.
+
Les couleurs sont dans `packages/ui/src/colors.ts`. Une fois la réponse validée, la question est remplacée par la ligne `◆ Question : choix` dans le journal.
+Barre de contexte : sous la zone de saisie, en bas à droite, une barre de carrés ■ (de gauche à droite) montre la taille du contexte de la conversation par rapport à la taille maximale du modèle, à l'échelle **1 carré = 10 000 tokens** (200 000 tokens → 20 carrés), avec le libellé « 45.7k / 200k · 23% ». Les carrés sont verts, jaunes à partir de 60 %, rouges à partir de 85 %. Si la barre ne tient pas dans le terminal (très grands contextes), l'échelle est agrandie par paliers de 10 000 et indiquée (`1■ = 20k`).
+- **Taille maximale** : après le choix du modèle (au démarrage, avec `--model`/config, et avec `/model`), MAIA demande au LLM « Taille maximale du contexte pour ce modèle » (réponse en tokens, lue par `parseTokenCount` : `128000`, `128 000 tokens`, `200k`, `1,5 million`…). La valeur est annoncée (INFO) ; une réponse par modèle et par exécution. Si la réponse est illisible, ou si la question échoue ou dépasse 60 s (Ctrl+C l'abandonne), un avertissement s'affiche et la barre est masquée. Un LLM peut se tromper sur sa propre limite : la valeur affichée en INFO permet de la vérifier.
+- **Contexte utilisé** : mis à jour après chaque réponse du LLM (donc après chaque appel d'outil intermédiaire) avec la consommation réelle renvoyée par LiteLLM (`stream_options.include_usage` : tokens de la requête + de la réponse). Sans consommation renvoyée, une estimation (≈ 4 caractères par token : prompt système, outils, messages) est utilisée. L'estimation sert aussi au démarrage, après `/clear`, `/new`, `/load` et après un tour interrompu.
+
La zone de saisie est encadrée par une bordure en pointillés (┈) **au-dessus et au-dessous** seulement, en `greenBright`, sans bords gauche ni droit.
À sa gauche, le spinner `arrow3` de [cli-spinners](https://github.com/sindresorhus/cli-spinners) s'anime en continu. Pendant l'attente de la réponse après validation de la saisie, « En réflexion » s'affiche avec le spinner `dots10` à sa gauche, en `yellowBright` (même spinner pour les autres attentes : chargement des modèles, exécution d'une commande `!`).
@@ -267,6 +273,7 @@ Exemple pour éviter les confirmations sur les outils en lecture seule : voir `s
- Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé).
- `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie.
- Liste à cocher (choix multiples) : n'existe pas encore ; elle recevrait le badge `ASK`.
+- Taille de contexte : lue en interrogeant le LLM (demande de l'utilisateur) ; la route `/model/info` de LiteLLM (`max_input_tokens`) serait plus fiable, et un réglage manuel dans `settings.json` pourrait corriger une réponse erronée.
- Nerd Fonts (icônes) : proposé comme réglage optionnel dans `settings.json`, non réalisé.
- Tests : les suites vitest et le typecheck sont à lancer après `pnpm install` (écrits sans avoir pu être exécutés dans l'environnement de développement).
@@ -294,3 +301,6 @@ Journal des évolutions, dans l'ordre :
18. **`/clear`** : supprime le contexte et recharge `MAIA.md` / `CLAUDE.md` (et les skills) comme au démarrage ; `/new` fait de même.
19. **Permissions granulaires** : règles `allow` / `ask` / `deny` par outil (`Outil` ou `Outil(motif)`), évaluées dans l'ordre deny > ask > allow, `ask` par défaut ; définies dans `~/.maia/settings.json` et `./.maia/settings.json` (pas le dossier parent).
20. **`/permissions`** : affiche les permissions chargées, par fichier et par type (badges ALLOW / ASK / DENY) ; les fichiers sont listés l'un sous l'autre au démarrage.
+21. **Rendu markdown** des réponses de l'agent via `markdansi`, dans un seul composant isolé (`packages/ui/src/markdown.tsx`), désactivable avec `MAIA_NO_MARKDOWN=1`.
+22. **Image de la bannière** : nouvelle image (visage à couronne), fond retiré, réduite à 32 × 32 en couleurs réelles (sans pixelisation ni réduction de palette), centrée verticalement à côté du titre ; données générées par `packages/ui/scripts/make-mask.py`.
+23. **Barre de progression du contexte** sous la zone de saisie (1 carré = 10 000 tokens) : taille maximale demandée au LLM après la sélection du modèle, contexte utilisé mis à jour après chaque réponse (consommation renvoyée par LiteLLM, sinon estimation) ; correction d'un import de type manquant dans `ink-ui.tsx`.
diff --git a/generated_page.md b/generated_page.md
new file mode 100644
index 0000000..1ade067
--- /dev/null
+++ b/generated_page.md
@@ -0,0 +1,33 @@
+# Les Mystères de l'Univers Numérique
+
+## Architecture Décentralisée et Résilience des Systèmes
+
+Dans le vaste paysage technologique moderne, les systèmes décentralisés représentent une avancée majeure. Ils permettent une meilleure distribution des charges et une résilience accrue face aux défaillances.
+
+## Innovation Quantique et Calcul de Demain
+
+La révolution quantique approche. Les ordinateurs quantiques promettent de résoudre des problèmes complexes en temps polynomial, transformant complètement nos capacités de calcul actuelles.
+
+## Sécurité Cryptographique et Protection des Données
+
+La cryptographie post-quantique devient essentielle. Avec l'émergence des ordinateurs quantiques, les algorithmes classiques pourraient être compromis, d'où la nécessité de nouvelles approches.
+
+## Intelligence Artificielle et Apprentissage Profond
+
+Les réseaux de neurones profonds continuent d'évoluer. L'IA générative ouvre des perspectives inédites dans la création, l'automatisation et l'assistance intelligente.
+
+## Durabilité Écologique du Développement Logiciel
+
+La conscience écologique grandit dans l'industrie tech. Optimiser les algorithmes et réduire la consommation énergétique devient une priorité stratégique.
+
+## Collaboration Distribuée et Télétravail Technologique
+
+Les outils collaboratifs modernes permettent des équipes vraiment distribuées. Le télétravail en tech s'accompagne d'une infrastructure logicielle sophistiquée et intuitive.
+
+## Automatisation DevOps et Intégration Continue
+
+Le DevOps transforme la façon dont nous livrons les applications. L'intégration continue et le déploiement continu deviennent la norme industrielle incontournable.
+
+## Microservices et Architecture Modulaire
+
+L'approche microservices rompt avec les monolithes. Elle offre une flexibilité accrue, une évolutivité meilleure et une maintenance simplifiée pour les grandes applications.
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index c019e58..98d0a08 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -6,11 +6,13 @@ import {
SessionStore,
createSession,
ensureMaiaHome,
+ estimateSessionTokens,
findSkill,
normalizeSessionName,
parseShellInvocation,
resolveFileReferences,
parseSkillInvocation,
+ queryContextWindow,
runTurn,
type AgentSession,
type Message,
@@ -24,6 +26,7 @@ import { runUserShell } from "./user-shell";
const REPLAY_RESULT_MAX = 600;
const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints
+const CONTEXT_QUERY_TIMEOUT_MS = 60_000; // question « taille maximale du contexte » posée au LLM
/** Réaffiche le contenu d'une session rechargée (échanges, appels d'outils, résultats tronqués). */
function replayMessages(ui: InkUI, session: AgentSession, messages: Message[]): void {
@@ -152,7 +155,51 @@ async function run(ui: InkUI, flags: { model?: string }): Promise {
log: (m) => ui.info(m),
warn: (m) => ui.warn(m),
});
- return createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd });
+ const created = createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd });
+ refreshContext(created); // le contexte de départ (prompt système, MAIA.md, outils) occupe déjà des tokens
+ return created;
+ };
+
+ /** Recalcule (estimation) la taille du contexte et met à jour la barre : nouvelle conversation, session rechargée, tour interrompu. */
+ const refreshContext = (target: AgentSession): void => {
+ target.contextTokens = estimateSessionTokens(target);
+ ui.onContextUsage(target.contextTokens);
+ };
+
+ // Tailles maximales de contexte déjà demandées au LLM (une seule question par modèle et par exécution)
+ const contextWindows = new Map();
+
+ /**
+ * Après la sélection d'un modèle : demande au LLM la taille maximale de son contexte (en tokens) et la donne à la barre
+ * de progression. Si la réponse est introuvable ou illisible, la barre est masquée (l'application continue).
+ */
+ const resolveContextWindow = async (spec: string): Promise => {
+ let max = contextWindows.get(spec) ?? null;
+ if (max === null) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), CONTEXT_QUERY_TIMEOUT_MS);
+ ui.setInterruptHandler(() => controller.abort()); // Ctrl+C abandonne la question
+ ui.setBusy("Recherche de la taille maximale du contexte…");
+ try {
+ const { tokens, answer } = await queryContextWindow(createProvider(spec, settings), controller.signal);
+ if (tokens === null) {
+ const shown = answer.length > 120 ? `${answer.slice(0, 120)}…` : answer;
+ ui.warn(`Taille maximale du contexte non déterminée (réponse du modèle : « ${shown} »). Barre de progression masquée.`);
+ } else {
+ max = tokens;
+ contextWindows.set(spec, tokens);
+ ui.info(`Taille maximale du contexte : ${tokens.toLocaleString("fr-FR")} tokens (réponse du modèle)`);
+ }
+ } catch (err) {
+ const why = controller.signal.aborted ? "question abandonnée (délai dépassé ou Ctrl+C)" : (err as Error).message;
+ ui.warn(`Taille maximale du contexte indisponible : ${why}. Barre de progression masquée.`);
+ } finally {
+ clearTimeout(timer);
+ ui.setBusy(null);
+ ui.setInterruptHandler(null);
+ }
+ }
+ ui.setContextMax(max);
};
ui.intro("Maia");
@@ -170,8 +217,10 @@ async function run(ui: InkUI, flags: { model?: string }): Promise {
}
// 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur
- let modelSpec = config.model;
- if (!modelSpec) {
+ let modelSpec: string;
+ if (config.model) {
+ modelSpec = config.model;
+ } else {
const picked = await pickModel();
if (!picked) {
ui.outro("Aucun modèle choisi");
@@ -190,6 +239,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise {
return;
}
ui.info(`Modèle : ${modelSpec}`);
+ await resolveContextWindow(modelSpec);
// Sauvegarde de la session courante (silencieuse si elle est vide ou inchangée). Retourne son nom si écrite.
const saveSession = (): string | null => {
@@ -351,6 +401,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise {
session.pendingContext.length = 0;
session.pendingContext.push(...saved.pendingContext);
sessions.restore(saved);
+ refreshContext(session);
ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`);
if (session.pendingContext.length > 0) {
ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`);
@@ -378,6 +429,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise {
session.provider = createProvider(choice, settings);
modelSpec = choice;
ui.info(`Modèle actif : ${modelSpec}`);
+ await resolveContextWindow(modelSpec);
} catch (err) {
ui.error((err as Error).message);
}
@@ -418,6 +470,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise {
if (controller.signal.aborted) {
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
+ refreshContext(session);
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
} else {
ui.error((err as Error).message);
diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts
index 412a7e4..b9662cc 100644
--- a/packages/core/src/agent.test.ts
+++ b/packages/core/src/agent.test.ts
@@ -122,3 +122,56 @@ describe("contexte en attente", () => {
expect(session.pendingContext).toEqual([]);
});
});
+
+describe("taille du contexte", () => {
+ const echo: Tool = {
+ spec: { name: "echo", description: "echo", inputSchema: { type: "object" } },
+ permission: "auto",
+ execute: async () => "ok",
+ };
+
+ it("utilise la consommation renvoyée par le serveur après chaque réponse", async () => {
+ const seen: number[] = [];
+ let call = 0;
+ const provider: LLMProvider = {
+ id: "fake",
+ model: "fake",
+ capabilities: { tools: true },
+ async *chat(): AsyncIterable {
+ call++;
+ if (call === 1) {
+ yield { type: "tool_use", id: "t1", name: "echo", input: {} };
+ yield { type: "usage", inputTokens: 1000, outputTokens: 50 };
+ } else {
+ yield { type: "text_delta", text: "fin" };
+ yield { type: "usage", inputTokens: 1200, outputTokens: 30 };
+ }
+ yield { type: "done", stopReason: call === 1 ? "tool_use" : "end_turn" };
+ },
+ };
+ const session = createSession({
+ provider,
+ init: { systemPrompt: "t", tools: [echo] },
+ ui: { ...silentUI, onContextUsage: (n) => seen.push(n) },
+ cwd: process.cwd(),
+ });
+ await runTurn(session, "go");
+ expect(seen).toEqual([1050, 1230]);
+ expect(session.contextTokens).toBe(1230);
+ });
+
+ it("sans consommation renvoyée : estimation à partir de la conversation", async () => {
+ const seen: number[] = [];
+ const session = createSession({
+ provider: fakeProvider("echo"),
+ init: { systemPrompt: "x".repeat(4000), tools: [echo] },
+ ui: { ...silentUI, onContextUsage: (n) => seen.push(n) },
+ cwd: process.cwd(),
+ });
+ await runTurn(session, "go");
+ expect(seen).toHaveLength(2);
+ expect(seen[0]).toBeGreaterThanOrEqual(1000); // prompt système de 4000 caractères
+ expect(seen[1]).toBeGreaterThan(seen[0]!);
+ expect(session.contextTokens).toBe(seen[1]);
+ });
+});
diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts
index 7a448d2..678afc6 100644
--- a/packages/core/src/agent.ts
+++ b/packages/core/src/agent.ts
@@ -1,3 +1,4 @@
+import { estimateTokens } from "./context";
import type {
AgentSkill,
AgentUI,
@@ -22,6 +23,8 @@ export interface AgentSession {
permissionSources: PermissionSource[];
env?: Record;
messages: Message[];
+ /** Taille du contexte (tokens) après la dernière réponse du LLM : valeur renvoyée par le serveur, sinon estimation. */
+ contextTokens: number;
/** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */
pendingContext: string[];
ui: AgentUI;
@@ -41,9 +44,10 @@ export function createSession(opts: {
commands: opts.init.commands ?? [],
skills: opts.init.skills ?? [],
permissions: opts.init.permissions,
- permissionSources: opts.init.permissionSources ?? [],
+ permissionSources: opts.init.permissionSources ?? [],
env: opts.init.env,
messages: [],
+ contextTokens: 0,
pendingContext: [],
ui: opts.ui,
cwd: opts.cwd,
@@ -69,6 +73,7 @@ export async function runTurn(
for (let i = 0; i < MAX_ITERATIONS; i++) {
const assistant: ContentBlock[] = [];
let text = "";
+ let usedTokens: number | null = null;
ui.onThinking(true);
let first = true;
@@ -90,6 +95,9 @@ export async function runTurn(
ui.onText(ev.text);
} else if (ev.type === "tool_use") {
assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input });
+ } else if (ev.type === "usage") {
+ // Requête + réponse : c'est la taille du contexte une fois la réponse ajoutée à la conversation
+ usedTokens = ev.inputTokens + ev.outputTokens;
}
// "done" : la fin du flux suffit, les appels d'outils décident de la suite
}
@@ -102,6 +110,8 @@ export async function runTurn(
assistant.unshift({ type: "text", text });
}
session.messages.push({ role: "assistant", content: assistant });
+ session.contextTokens = usedTokens ?? estimateSessionTokens(session);
+ ui.onContextUsage?.(session.contextTokens);
const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use");
if (calls.length === 0) return;
@@ -161,3 +171,8 @@ async function executeToolCall(
return result(msg, true);
}
}
+
+/** Estimation de la taille du contexte d'une session (sans réponse du serveur) : prompt système, outils et messages. */
+export function estimateSessionTokens(session: Pick): number {
+ return estimateTokens(session.system, session.messages, [...session.tools.values()].map((t) => t.spec));
+}
diff --git a/packages/core/src/context.test.ts b/packages/core/src/context.test.ts
new file mode 100644
index 0000000..b78e610
--- /dev/null
+++ b/packages/core/src/context.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { CONTEXT_WINDOW_QUESTION, estimateTokens, parseTokenCount, queryContextWindow } from "./context";
+import type { LLMProvider, StreamEvent } from "./types";
+
+describe("parseTokenCount", () => {
+ it.each([
+ ["128000", 128_000],
+ ["128 000", 128_000],
+ ["128 000 tokens", 128_000],
+ ["131,072", 131_072],
+ ["1.000.000", 1_000_000],
+ ["200k", 200_000],
+ ["200K tokens", 200_000],
+ ["1M", 1_000_000],
+ ["1,5 million de tokens", 1_500_000],
+ ["2 millions", 2_000_000],
+ ["1.5M", 1_500_000],
+ ["Environ 128 000 tokens.", 128_000],
+ ])("%s → %d", (text, expected) => {
+ expect(parseTokenCount(text)).toBe(expected);
+ });
+
+ it("ignore les nombres du nom du modèle", () => {
+ expect(parseTokenCount("GPT-4o a un contexte de 128 000 tokens")).toBe(128_000);
+ expect(parseTokenCount("Claude 3.5 Sonnet : 200k tokens")).toBe(200_000);
+ });
+
+ it("une valeur avec unité l'emporte sur une valeur plus grande sans unité", () => {
+ expect(parseTokenCount("sortie 16 384 ; contexte 128k tokens ; ref 5000000")).toBe(128_000);
+ });
+
+ it("sans valeur exploitable → null", () => {
+ expect(parseTokenCount("Je ne sais pas.")).toBeNull();
+ expect(parseTokenCount("")).toBeNull();
+ expect(parseTokenCount("42")).toBeNull();
+ });
+});
+
+describe("estimateTokens", () => {
+ it("≈ 4 caractères par token, outils compris", () => {
+ expect(estimateTokens("a".repeat(400), [])).toBeGreaterThanOrEqual(100);
+ const without = estimateTokens("s", []);
+ const withTools = estimateTokens("s", [], [{ name: "T", description: "d".repeat(400), inputSchema: {} }]);
+ expect(withTools).toBeGreaterThan(without + 90);
+ });
+});
+
+describe("queryContextWindow", () => {
+ const provider = (reply: string, seen: { text?: string } = {}): LLMProvider => ({
+ id: "fake",
+ model: "fake",
+ capabilities: { tools: false },
+ async *chat(req): AsyncIterable {
+ seen.text = JSON.stringify(req.messages);
+ yield { type: "text_delta", text: reply.slice(0, 3) };
+ yield { type: "text_delta", text: reply.slice(3) };
+ yield { type: "done", stopReason: "end_turn" };
+ },
+ });
+
+ it("pose la question et lit le nombre de tokens", async () => {
+ const seen: { text?: string } = {};
+ const r = await queryContextWindow(provider("200 000 tokens", seen));
+ expect(r).toEqual({ tokens: 200_000, answer: "200 000 tokens" });
+ expect(seen.text).toContain(CONTEXT_WINDOW_QUESTION);
+ expect(CONTEXT_WINDOW_QUESTION).toMatch(/Taille maximale du contexte pour ce modèle/);
+ });
+
+ it("réponse illisible : tokens null, réponse conservée", async () => {
+ expect(await queryContextWindow(provider("Aucune idée"))).toEqual({ tokens: null, answer: "Aucune idée" });
+ });
+});
diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts
new file mode 100644
index 0000000..a814670
--- /dev/null
+++ b/packages/core/src/context.ts
@@ -0,0 +1,77 @@
+// Taille du contexte : estimation locale, lecture d'une taille en tokens, question posée au LLM.
+import type { LLMProvider, Message, ToolSpec } from "./types";
+
+/** Estimation grossière (≈ 4 caractères par token) quand le serveur ne renvoie pas la consommation réelle. */
+export function estimateTokens(system: string, messages: Message[], tools: ToolSpec[] = []): number {
+ const chars = system.length + JSON.stringify(messages).length + (tools.length > 0 ? JSON.stringify(tools).length : 0);
+ return Math.ceil(chars / 4);
+}
+
+/** Question posée au LLM après le choix du modèle (la consigne finale facilite la lecture de la réponse). */
+export const CONTEXT_WINDOW_QUESTION =
+ "Taille maximale du contexte pour ce modèle, en tokens ? Réponds uniquement par un nombre entier, sans autre texte.";
+
+const MIN_CONTEXT = 1_000;
+const MAX_CONTEXT = 100_000_000;
+
+const MULTIPLIERS: Record = {
+ k: 1_000,
+ mille: 1_000,
+ m: 1_000_000,
+ million: 1_000_000,
+ millions: 1_000_000,
+ milliard: 1_000_000_000,
+ milliards: 1_000_000_000,
+};
+
+// 128 000 · 128,000 · 128.000 · 1.5 · 1,5 · 200000 — suivi éventuel d'un multiplicateur (k, M, mille, million…) ou de « token »
+const NUMBER = /(\d{1,3}(?:[ ., ]\d{3})+(?!\d)|\d+(?:[.,]\d+)?)\s*(milliards?|millions?|mille|[kKmM](?![A-Za-zÀ-ÿ])|(?=tokens?\b))?/gi;
+
+function toNumber(raw: string): number {
+ if (/^\d{1,3}([ ., ]\d{3})+$/.test(raw)) return Number(raw.replace(/[ ., ]/g, ""));
+ return Number(raw.replace(",", "."));
+}
+
+/**
+ * Extrait une taille de contexte (en tokens) de la réponse d'un LLM : « 128000 », « 128 000 tokens », « 200k », « 1M »,
+ * « 1,5 million »… Une valeur avec unité l'emporte ; sinon la plus grande valeur plausible (1 000 à 100 000 000).
+ * Retourne null si rien d'exploitable.
+ */
+export function parseTokenCount(text: string): number | null {
+ let best: { value: number; unit: boolean } | null = null;
+ for (const m of text.matchAll(NUMBER)) {
+ const base = toNumber(m[1] ?? "");
+ const suffix = (m[2] ?? "").toLowerCase();
+ const value = Math.round(base * (MULTIPLIERS[suffix] ?? 1));
+ if (!Number.isFinite(value) || value < MIN_CONTEXT || value > MAX_CONTEXT) continue;
+ const unit = suffix !== "" || /^\s*tokens?\b/i.test(text.slice((m.index ?? 0) + m[0].length));
+ if (!best || (unit && !best.unit) || (unit === best.unit && value > best.value)) best = { value, unit };
+ }
+ return best ? best.value : null;
+}
+
+export interface ContextWindowAnswer {
+ /** Taille maximale en tokens, null si la réponse n'a pas pu être lue. */
+ tokens: number | null;
+ /** Réponse brute du LLM (utile pour informer l'utilisateur en cas d'échec). */
+ answer: string;
+}
+
+/** Demande au LLM la taille maximale de son contexte. Lève une erreur si l'appel échoue (réseau, HTTP, annulation). */
+export async function queryContextWindow(
+ provider: LLMProvider,
+ signal?: AbortSignal,
+): Promise {
+ let answer = "";
+ for await (const ev of provider.chat(
+ {
+ system: "Tu réponds de façon brève et exacte.",
+ messages: [{ role: "user", content: [{ type: "text", text: CONTEXT_WINDOW_QUESTION }] }],
+ tools: [],
+ },
+ signal,
+ )) {
+ if (ev.type === "text_delta") answer += ev.text;
+ }
+ return { tokens: parseTokenCount(answer), answer: answer.trim() };
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 3db59f4..c8a6880 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -4,3 +4,4 @@ export * from "./skills";
export * from "./shell";
export * from "./fileref";
export * from "./sessions";
+export * from "./context";
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 483291c..c68c2b4 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -36,6 +36,8 @@ export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "other";
export type StreamEvent =
| { type: "text_delta"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record }
+ /** Consommation de la requête, quand le serveur la renvoie (avant "done"). */
+ | { type: "usage"; inputTokens: number; outputTokens: number }
| { type: "done"; stopReason: StopReason };
export interface LLMProvider {
@@ -89,6 +91,8 @@ export interface AgentUI {
onToolCall(name: string, summary: string): void;
onToolResult(name: string, output: string, isError: boolean): void;
confirm(message: string): Promise;
+ /** Taille courante du contexte (en tokens), après chaque réponse du LLM. */
+ onContextUsage?(tokens: number): void;
}
// ---------- Initialisation de l'agent ----------
diff --git a/packages/providers/src/litellm/litellm.test.ts b/packages/providers/src/litellm/litellm.test.ts
index ca33046..50dd768 100644
--- a/packages/providers/src/litellm/litellm.test.ts
+++ b/packages/providers/src/litellm/litellm.test.ts
@@ -62,7 +62,7 @@ describe("format OpenAI", () => {
{ role: "user", content: [{ type: "text", text: "suite" }, { type: "tool_result", toolUseId: "c1", content: "R" }] },
],
});
- expect(body).toMatchObject({ model: "m", stream: true, max_tokens: 100 });
+ expect(body).toMatchObject({ model: "m", stream: true, max_tokens: 100, stream_options: { include_usage: true } });
const msgs = body.messages as { role: string }[];
expect(msgs.map((m) => m.role)).toEqual(["system", "user", "assistant", "tool", "user"]);
expect(msgs[2]).toMatchObject({ tool_calls: [{ id: "c1", function: { name: "Read", arguments: '{"a":1}' } }] });
@@ -95,6 +95,18 @@ describe("format OpenAI", () => {
]);
});
+ it("streaming : consommation (tokens) lue dans le dernier morceau, avant « done »", async () => {
+ const raw =
+ sse({ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }) +
+ sse({ choices: [], usage: { prompt_tokens: 1200, completion_tokens: 30, total_tokens: 1230 } }) +
+ "data: [DONE]\n\n";
+ expect(await collect(parseSse(chunks(raw)))).toEqual([
+ { type: "text_delta", text: "ok" },
+ { type: "usage", inputTokens: 1200, outputTokens: 30 },
+ { type: "done", stopReason: "end_turn" },
+ ]);
+ });
+
it("erreur renvoyée dans le flux", async () => {
await expect(collect(parseSse(chunks(sse({ error: { message: "boom" } }))))).rejects.toThrow(/boom/);
});
diff --git a/packages/providers/src/litellm/openai.ts b/packages/providers/src/litellm/openai.ts
index 2800b7f..101212b 100644
--- a/packages/providers/src/litellm/openai.ts
+++ b/packages/providers/src/litellm/openai.ts
@@ -26,7 +26,8 @@ export function buildChatBody(model: string, req: ChatRequest): Record = { model, messages, stream: true };
+ // include_usage : le serveur renvoie la consommation réelle (tokens) dans le dernier morceau du flux
+ const body: Record = { model, messages, stream: true, stream_options: { include_usage: true } };
if (req.maxTokens) body.max_tokens = req.maxTokens;
if (req.tools.length > 0) {
body.tools = req.tools.map((t) => ({
@@ -39,6 +40,7 @@ export function buildChatBody(model: string, req: ChatRequest): Record): AsyncGenerator
const decoder = new TextDecoder();
const calls = new Map();
let finish: string | null | undefined;
+ // objet plutôt que variable : l'assignation se fait dans un générateur imbriqué (TypeScript ne la verrait pas)
+ const seen: { usage: { inputTokens: number; outputTokens: number } | null } = { usage: null };
let buffer = "";
function* handleLine(line: string): Generator {
@@ -73,6 +77,12 @@ export async function* parseSse(body: AsyncIterable): AsyncGenerator
return;
}
if (chunk.error) throw new Error(`Erreur du serveur LiteLLM : ${chunk.error.message ?? JSON.stringify(chunk.error)}`);
+ // Le morceau d'usage arrive en dernier, avec une liste `choices` vide
+ if (chunk.usage && typeof chunk.usage.prompt_tokens === "number") {
+ const input = chunk.usage.prompt_tokens;
+ const output = chunk.usage.completion_tokens ?? Math.max(0, (chunk.usage.total_tokens ?? input) - input);
+ seen.usage = { inputTokens: input, outputTokens: output };
+ }
const choice = chunk.choices?.[0];
if (!choice) return;
const delta = choice.delta;
@@ -111,5 +121,6 @@ export async function* parseSse(body: AsyncIterable): AsyncGenerator
}
yield { type: "tool_use", id: call.id || `call_${++n}`, name: call.name, input };
}
+ if (seen.usage) yield { type: "usage", ...seen.usage };
yield { type: "done", stopReason: stopReason(finish, calls.size > 0) };
}
diff --git a/packages/ui/assets/mask.png b/packages/ui/assets/mask.png
new file mode 100644
index 0000000..2fc32d8
Binary files /dev/null and b/packages/ui/assets/mask.png differ
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 39e8b3a..df17b30 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -12,6 +12,7 @@
"@maia/core": "workspace:*",
"cli-spinners": "^2.9.2",
"ink": "^7.0.3",
+ "markdansi": "^0.2.0",
"react": "^19.2.0"
},
"devDependencies": {
diff --git a/packages/ui/scripts/make-mask.py b/packages/ui/scripts/make-mask.py
new file mode 100644
index 0000000..cc64966
--- /dev/null
+++ b/packages/ui/scripts/make-mask.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""Génère src/mask-data.ts à partir de assets/mask.png (nécessite Pillow : pip install pillow).
+
+Usage : python3 scripts/make-mask.py [taille] (32 par défaut ; l'image affichée fait taille × taille pixels,
+soit taille colonnes × taille/2 lignes de terminal)
+
+Le fond uni (couleur des coins) est rendu transparent, l'image est recadrée au carré puis réduite
+en moyennant les couleurs : aucune pixelisation par blocs, aucune réduction de palette.
+"""
+import sys
+from collections import deque
+from pathlib import Path
+from PIL import Image
+
+N = int(sys.argv[1]) if len(sys.argv) > 1 else 32
+root = Path(__file__).resolve().parent.parent
+im = Image.open(root / "assets" / "mask.png").convert("RGB")
+W, H = im.size
+px = im.load()
+bg = px[0, 0]
+near = lambda c: all(abs(c[i] - bg[i]) <= 12 for i in range(3))
+
+# Fond = zone connectée aux coins et proche de la couleur du fond (le visage crème est isolé par son contour noir).
+transparent = [[False] * W for _ in range(H)]
+queue = deque([(0, 0), (W - 1, 0), (0, H - 1), (W - 1, H - 1)])
+while queue:
+ x, y = queue.popleft()
+ if x < 0 or y < 0 or x >= W or y >= H or transparent[y][x] or not near(px[x, y]):
+ continue
+ transparent[y][x] = True
+ queue.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)])
+
+xs = [x for y in range(H) for x in range(W) if not transparent[y][x]]
+ys = [y for y in range(H) for x in range(W) if not transparent[y][x]]
+x0, x1, y0, y1 = min(xs), max(xs) + 1, min(ys), max(ys) + 1
+side = max(x1 - x0, y1 - y0)
+ox = x0 - (side - (x1 - x0)) // 2
+oy = y0 - (side - (y1 - y0)) // 2
+
+rows = []
+for gy in range(N):
+ row = ""
+ for gx in range(N):
+ total = opaque = 0
+ acc = [0, 0, 0]
+ for y in range(int(oy + gy * side / N), int(oy + (gy + 1) * side / N) + 1):
+ for x in range(int(ox + gx * side / N), int(ox + (gx + 1) * side / N) + 1):
+ total += 1
+ if 0 <= x < W and 0 <= y < H and not transparent[y][x]:
+ opaque += 1
+ for i in range(3):
+ acc[i] += px[x, y][i]
+ # moyenne des pixels opaques uniquement (évite le liseré clair du fond) ; transparent si moins de la moitié est opaque
+ row += "......" if opaque * 2 < total or opaque == 0 else "".join("%02x" % round(a / opaque) for a in acc)
+ rows.append(row)
+
+lines = "\n".join(f' "{r}",' for r in rows)
+(root / "src" / "mask-data.ts").write_text(
+ "// Fichier généré par scripts/make-mask.py à partir de assets/mask.png : ne pas modifier à la main.\n"
+ "// Une chaîne par ligne de pixels ; chaque pixel = 6 chiffres hexadécimaux rrggbb, \"......\" = transparent.\n"
+ f"export const MASK_SIZE = {N};\n\n"
+ f"export const MASK_DATA: string[] = [\n{lines}\n];\n",
+ encoding="utf-8",
+)
+print(f"{N}x{N} écrit dans src/mask-data.ts")
diff --git a/packages/ui/src/banner.tsx b/packages/ui/src/banner.tsx
index 4b42895..4aafd35 100644
--- a/packages/ui/src/banner.tsx
+++ b/packages/ui/src/banner.tsx
@@ -90,7 +90,7 @@ export function Banner({ title }: { title: string }) {
if (columns < MASK_WIDTH + GAP + titleWidth(title)) return right;
return (
-
+
{right}
diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx
index 7294ac0..5ecfea8 100644
--- a/packages/ui/src/components.tsx
+++ b/packages/ui/src/components.tsx
@@ -5,6 +5,8 @@ import { Badge } from "@inkjs/ui";
import { Box, Static, Text, useInput } from "ink";
import { Banner } from "./banner";
import { ERROR_TEXT, LIGHT_BLUE } from "./colors";
+import { ContextBar } from "./context-bar";
+import { Markdown } from "./markdown";
import type {
ConfirmPromptState,
InputPromptState,
@@ -12,6 +14,7 @@ import type {
PermissionFile,
PromptState,
SelectPromptState,
+ UIState,
UIStore,
} from "./store";
@@ -108,7 +111,8 @@ function LogLine({ item }: { item: LogItem }) {
);
case "assistant":
- return {item.text};
+ // Réponse finale rendue en markdown (voir markdown.tsx) ; le texte en cours de streaming reste brut.
+ return ;
case "permissions":
return ;
case "shell":
@@ -346,10 +350,15 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
);
}
-function PromptView({ prompt }: { prompt: PromptState }) {
+function PromptView({ prompt, state }: { prompt: PromptState; state: UIState }) {
switch (prompt.kind) {
case "input":
- return ;
+ return (
+
+
+ {state.contextMax !== null ? : null}
+
+ );
case "select":
return ;
case "confirm":
@@ -390,7 +399,7 @@ export function App({ store }: { store: UIStore }) {
) : null}
- {state.prompt ? : null}
+ {state.prompt ? : null}
>
);
}
diff --git a/packages/ui/src/context-bar.tsx b/packages/ui/src/context-bar.tsx
new file mode 100644
index 0000000..bbd5d27
--- /dev/null
+++ b/packages/ui/src/context-bar.tsx
@@ -0,0 +1,24 @@
+// Barre de progression du contexte (sous la zone de saisie, alignée à droite, de gauche à droite).
+import React from "react";
+import { Box, Text, useStdout } from "ink";
+import { contextBarModel, formatTokens } from "./context-model";
+
+const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const;
+const EMPTY_COLOR = "#555555";
+
+export function ContextBar({ used, max }: { used: number; max: number }) {
+ const { stdout } = useStdout();
+ const columns = stdout?.columns ?? 80;
+ const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100);
+ const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;
+ // Marge d'1 colonne de chaque côté ; le libellé passe avant les carrés
+ const model = contextBarModel(used, max, Math.max(1, columns - label.length - 2));
+ const scale = model.tokensPerSquare === 10_000 ? "" : ` (1■ = ${formatTokens(model.tokensPerSquare)})`;
+ return (
+
+ {label.trimEnd() + scale + " "}
+ {"■".repeat(model.filled)}
+ {"■".repeat(model.total - model.filled)}
+
+ );
+}
diff --git a/packages/ui/src/context-model.test.ts b/packages/ui/src/context-model.test.ts
new file mode 100644
index 0000000..eb132aa
--- /dev/null
+++ b/packages/ui/src/context-model.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { TOKENS_PER_SQUARE, contextBarModel, formatTokens } from "./context-model";
+
+describe("contextBarModel", () => {
+ it("1 carré = 10 000 tokens : 200 000 tokens → 20 carrés", () => {
+ expect(TOKENS_PER_SQUARE).toBe(10_000);
+ expect(contextBarModel(0, 200_000)).toMatchObject({ total: 20, filled: 0, tokensPerSquare: 10_000, level: "ok" });
+ });
+
+ it("la barre se remplit de la gauche vers la droite selon l'usage", () => {
+ expect(contextBarModel(50_000, 200_000).filled).toBe(5);
+ expect(contextBarModel(100_000, 200_000)).toMatchObject({ filled: 10, ratio: 0.5, level: "ok" });
+ expect(contextBarModel(130_000, 200_000)).toMatchObject({ filled: 13, level: "warn" });
+ expect(contextBarModel(180_000, 200_000)).toMatchObject({ filled: 18, level: "danger" });
+ });
+
+ it("un contexte non vide montre au moins un carré ; un contexte plein les remplit tous", () => {
+ expect(contextBarModel(300, 200_000).filled).toBe(1);
+ expect(contextBarModel(200_000, 200_000)).toMatchObject({ filled: 20, ratio: 1 });
+ });
+
+ it("dépassement du maximum : plafonné", () => {
+ expect(contextBarModel(250_000, 200_000)).toMatchObject({ filled: 20, ratio: 1, level: "danger" });
+ });
+
+ it("taille non multiple de 10 000 : carré partiel arrondi vers le haut au total", () => {
+ expect(contextBarModel(0, 128_000).total).toBe(13);
+ });
+
+ it("barre trop large pour le terminal : échelle agrandie par paliers de 10 000", () => {
+ const m = contextBarModel(500_000, 1_000_000, 60); // 100 carrés demandés, 60 disponibles
+ expect(m.tokensPerSquare).toBe(20_000);
+ expect(m.total).toBe(50);
+ expect(m.filled).toBe(25);
+ });
+});
+
+describe("formatTokens", () => {
+ it.each([
+ [950, "950"],
+ [12_345, "12.3k"],
+ [128_000, "128k"],
+ [200_000, "200k"],
+ [1_048_576, "1.05M"],
+ [1_000_000, "1M"],
+ ])("%d → %s", (n, expected) => expect(formatTokens(n)).toBe(expected));
+});
diff --git a/packages/ui/src/context-model.ts b/packages/ui/src/context-model.ts
new file mode 100644
index 0000000..ca7adc0
--- /dev/null
+++ b/packages/ui/src/context-model.ts
@@ -0,0 +1,47 @@
+// Barre de progression du contexte : calculs purs (sans React), testables seuls.
+
+/** Échelle par défaut : 1 carré = 10 000 tokens. */
+export const TOKENS_PER_SQUARE = 10_000;
+
+export interface ContextBarModel {
+ /** Nombre total de carrés. */
+ total: number;
+ /** Carrés remplis (au moins 1 dès que le contexte n'est pas vide). */
+ filled: number;
+ /** Tokens représentés par un carré (10 000, sauf si la barre est trop large pour le terminal). */
+ tokensPerSquare: number;
+ /** Part du contexte utilisée, de 0 à 1 (plafonnée). */
+ ratio: number;
+ /** vert : confortable · jaune : à surveiller · rouge : presque plein. */
+ level: "ok" | "warn" | "danger";
+}
+
+/**
+ * @param used tokens du contexte actuel
+ * @param max taille maximale du contexte du modèle
+ * @param maxSquares largeur disponible pour la barre ; au-delà, l'échelle est agrandie (1 carré = plus de 10 000 tokens)
+ */
+export function contextBarModel(used: number, max: number, maxSquares = Infinity): ContextBarModel {
+ const safeMax = Math.max(1, max);
+ const safeUsed = Math.max(0, used);
+ let tokensPerSquare = TOKENS_PER_SQUARE;
+ if (Math.ceil(safeMax / tokensPerSquare) > maxSquares) {
+ // Échelle agrandie par paliers de 10 000 pour que la barre tienne dans la largeur disponible
+ tokensPerSquare = Math.ceil(safeMax / Math.max(1, maxSquares) / TOKENS_PER_SQUARE) * TOKENS_PER_SQUARE;
+ }
+ const total = Math.max(1, Math.ceil(safeMax / tokensPerSquare));
+ const ratio = Math.min(1, safeUsed / safeMax);
+ const filled = safeUsed === 0 ? 0 : Math.min(total, Math.max(1, Math.round(safeUsed / tokensPerSquare)));
+ const level = ratio >= 0.85 ? "danger" : ratio >= 0.6 ? "warn" : "ok";
+ return { total, filled, tokensPerSquare, ratio, level };
+}
+
+/** 950 → "950" · 12 345 → "12.3k" · 200 000 → "200k" · 1 048 576 → "1.05M". */
+export function formatTokens(n: number): string {
+ if (n < 1_000) return String(Math.round(n));
+ if (n < 1_000_000) {
+ const k = n / 1_000;
+ return `${k >= 100 ? Math.round(k) : Math.round(k * 10) / 10}k`;
+ }
+ return `${Math.round((n / 1_000_000) * 100) / 100}M`;
+}
diff --git a/packages/ui/src/ink-ui.tsx b/packages/ui/src/ink-ui.tsx
index 046157f..8e860c9 100644
--- a/packages/ui/src/ink-ui.tsx
+++ b/packages/ui/src/ink-ui.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { render, type Instance } from "ink";
import type { AgentUI } from "@maia/core";
import { App } from "./components";
-import { UIStore, type Choice } from "./store";
+import { UIStore, type Choice, type PermissionFile } from "./store";
/**
* Interface Ink (React pour CLI). Façade impérative au-dessus d'un UIStore : le CLI appelle des méthodes
@@ -116,4 +116,13 @@ export class InkUI implements AgentUI {
onToolResult(name: string, output: string, isError: boolean): void {
this.start().toolResult(name, output, isError);
}
+ /** Taille du contexte (tokens) après chaque réponse du LLM : met à jour la barre de progression. */
+ onContextUsage(tokens: number): void {
+ this.start().setContextUsed(tokens);
+ }
+
+ /** Taille maximale du contexte du modèle (null : inconnue, la barre est masquée). */
+ setContextMax(tokens: number | null): void {
+ this.start().setContextMax(tokens);
+ }
}
diff --git a/packages/ui/src/markdown.test.ts b/packages/ui/src/markdown.test.ts
new file mode 100644
index 0000000..eb35c6c
--- /dev/null
+++ b/packages/ui/src/markdown.test.ts
@@ -0,0 +1,26 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { renderMarkdown } from "./markdown";
+
+describe("renderMarkdown", () => {
+ afterEach(() => {
+ delete process.env.MAIA_NO_MARKDOWN;
+ });
+
+ it("rend du markdown sans laisser les marqueurs de titre ni de gras", () => {
+ // eslint-disable-next-line no-control-regex
+ const plain = renderMarkdown("# Titre\n\nUn mot **important**.", 60).replace(/\u001b\[[0-9;]*m/g, "");
+ expect(plain).toContain("Titre");
+ expect(plain).toContain("important");
+ expect(plain).not.toContain("**");
+ expect(plain).not.toMatch(/^# /m);
+ });
+
+ it("ne termine pas par un saut de ligne", () => {
+ expect(renderMarkdown("Bonjour", 60).endsWith("\n")).toBe(false);
+ });
+
+ it("renvoie le texte brut si MAIA_NO_MARKDOWN=1", () => {
+ process.env.MAIA_NO_MARKDOWN = "1";
+ expect(renderMarkdown("# Titre", 60)).toBe("# Titre");
+ });
+});
diff --git a/packages/ui/src/markdown.tsx b/packages/ui/src/markdown.tsx
new file mode 100644
index 0000000..4df4bda
--- /dev/null
+++ b/packages/ui/src/markdown.tsx
@@ -0,0 +1,34 @@
+// Rendu markdown des réponses de l'agent.
+//
+// Composant volontairement isolé : c'est le SEUL fichier qui connaît `markdansi`.
+// Pour le retirer : supprimer ce fichier, retirer `markdansi` de package.json et
+// remplacer `` par `{…}` dans components.tsx (un seul endroit).
+import React from "react";
+import { Text } from "ink";
+import { render } from "markdansi";
+
+/** Largeur de repli quand le terminal ne la fournit pas (sortie redirigée, etc.). */
+const DEFAULT_WIDTH = 80;
+
+/**
+ * Convertit du markdown en texte ANSI. Ne lève jamais d'exception :
+ * en cas d'échec de markdansi (ou si MAIA_NO_MARKDOWN=1), renvoie le texte brut.
+ */
+export function renderMarkdown(text: string, width: number = terminalWidth()): string {
+ if (process.env.MAIA_NO_MARKDOWN === "1") return text;
+ try {
+ // trimEnd : markdansi termine par un saut de ligne, qui ajouterait une ligne vide sous la réponse.
+ return render(text, { width }).trimEnd();
+ } catch {
+ return text;
+ }
+}
+
+function terminalWidth(): number {
+ const columns = process.stdout.columns;
+ return typeof columns === "number" && columns > 0 ? columns : DEFAULT_WIDTH;
+}
+
+export function Markdown({ text }: { text: string }) {
+ return {renderMarkdown(text)};
+}
diff --git a/packages/ui/src/mask-data.ts b/packages/ui/src/mask-data.ts
new file mode 100644
index 0000000..090de84
--- /dev/null
+++ b/packages/ui/src/mask-data.ts
@@ -0,0 +1,38 @@
+// Fichier généré par scripts/make-mask.py à partir de assets/mask.png : ne pas modifier à la main.
+// Une chaîne par ligne de pixels ; chaque pixel = 6 chiffres hexadécimaux rrggbb, "......" = transparent.
+export const MASK_SIZE = 32;
+
+export const MASK_DATA: string[] = [
+ "..........................................................................................1514141d1c1c..........................................................................................",
+ "................................................1515151d1a0b1c1c1c..................1b1b1b7868205f531a212121..................1a1a1a1c19091a1a1a................................................",
+ "................................................222014ddbe3b5449181d1d1d............3e3613f7d442ebca3f2d2811............1818186a5c1dcbaf37161512................................................",
+ "..................1a1a19171615..................433a13f7d442e6c63e403813......191919ac942ec4a835d3b5398f7b261e1e1e......564b18ebca3ff9d643312c16..................171615191919..................",
+ "..................201c10baa132473e161b1b1b......594c18af972f72621fcbaf361d1c16282414f4d1417f6f239c872adebf3b1a1914242113e0c13c574b18cbaf373d3511......171716564b18ab942e0b0b0b..................",
+ "..................211e14f2d041f8d5427565201a1a19534816e7c63e362f0ff9d643544917483e13ffda447f6f239c872afeda442b260c70611eeac93f352e0ff8d54337300f1f1e1a8e7b26fdd843d5b839111111..................",
+ "..................151412e0c13ca48d2cf5d24267591c322b0dfeda44504516c2a6349b852a5b4e19ffda447f6f239c872affda443f3611b79d31a68e2d6c5d1df3d141161306837123ecca3faa922ec4a834141414..................",
+ "..................161615c4a93562551ba38c2cedcb3f2a250bd4b6399782297a6921d5b739887525ffda44837224a08a2bffda44776720e9c83e5e511ab39a30b89e313c3410f8d5428774247e6d22a8902d191919..................",
+ "..................1a1a1a9d872ad0b2383d3411f9d542b49b30d4b639e5c43da08a2bffda44ffda44ffda44eecc40f4d041ffda44ffda44ffda44968129f3d041c4a835c5a935eccb3f332c0ee5c43d806e22........................",
+ "........................66591cffda445e511aa78f2dffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda448a77257a6921ffda444b4115........................",
+ "........................282414eac93fcdaf37534717ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44fdd944443a13e3c23dd7b93a1f1d16........................",
+ "........................222222786720ffda44f0ce40ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44f1ce41feda445c5019..............................",
+ "..............................1c1a11d1b438ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44b99f32161512..............................",
+ "..............................181818322b0e64581b64581b64581b64581b64581b64581b64581b64581b64581b64581b695b1de1cd45b8a63764581ba1912ff0dd4b7d6e2364581b27220b1c1c1c..............................",
+ "........................1b15144a2c261f25108aa74489a74489a74489a74489a74489a74489a74489a74489a74489a7448daa46b9e15cabcf5589a744a2c551bde55e95b44a88a4431b180d4d2e281a1615........................",
+ "..................1b1b1b693e368c53482b3315b6dd5b53652a4c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c26607430bbe25d2622139b5c5058342d1c1c1c..................",
+ "..................18131294584c8c53482b3315abcf552a260cb79f32b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b49c312a2c11bae15d2622139b5c50834e430f0f0f..................",
+ "..................171110975a4e8c53482b3315abcf5527230ba9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da68f2d282b10bae15d2622139b5c50864f450a0a0a..................",
+ "..................171110975a4e8c53482b3315abcf551b1b19464444241512462a242415124543431a14146f2d2d6228281d1c1c3937372c1a17462a241c110f514f4f232717bae15d2622139b5c50864f450a0a0a..................",
+ "..................0e0b0b36201c351f1b2a3215abcf55393836a7a2a24127229b5c50422722a5a0a0392c2cf76363db5858413e3e89858553312b9a5c50301d19bfb9b9313524bae15d1b1c0e3a221e311d190a0a0a..................",
+ "............2d17178d3939bf4d4da743432b3215abcf55393836eee6e6484545241614484444eee5e5392c2cf76363db5858413e3ee0d8d83a35352316145c5959f5ecec313524bae15d271f12ba4b4bbc4c4c823434251616............",
+ "......391a1ae25b5ba643435e26265c25252b3215abcf55393836fff6f6fbf1f1e0d8d8fbf1f1e9e1e1352020fa6565e65d5d322b2bf6ededf8eeeee1d9d9fdf4f4fbf2f2313524bae15d221d10632828622828b74949d556562b1616......",
+ "151313c04d4d88373742091f9e164a86133f2a3115abcf55393836fff6f6fff5f5fff5f5fff5f5847f7f803434ff6666ff66666429299f9a9afff5f5fff5f5fff5f5fbf2f2313524bae15d21191097154798154838091aa54343a24141151515",
+ "301818f161612e0d14ca1c5fd81e66c31b5c2c3116abcf55393836fff6f6fff5f5fff5f5fff5f53e3c3cbb4b4bff6666ff66669f4040595757fff5f5fff5f5fff5f5fbf2f2313524bae15d2d1a16d71e66d81e66be1a5aa53645ec5f5f170e0e",
+ "371717e75d5d2e0815d81e66d81e66c81c5f2d2e16b0d558373833fff6f6fff5f5fff5f5cdc6c6413f3f260f0f5d25255d25251c0b0b525050d9d1d1fff5f5fff5f5f6eded303621b8de5b2f1617d81e66d81e66d71e66e74363f964641c0c0c",
+ "221717e85d5d481c1d931445d81e66d21d63281a13b7de5b2f3421f3eaeafff5f5c9c2c237311ba48e2cbfa433bfa433bfa433bea433968128322f22ddd5d5fff5f5d9d1d1333d1ba6c9532f0a16d81e66d81e667e113b7d3133ce5353151313",
+ "1918187c3232d7565640181b3a081b32091712080898b84c435121bab4b4fff5f5676464ae962fceb037ad942ead942ead942ead942ed7b83a887525837f7ffff5f59e9999586b2c839e4116080936081937081a4e1f20e45c5c5e2626......",
+ "......151111823535ec5f5fd45555df5a5a8034346378317f9a3f5f5c5cfff5f55b5958c0a533a28b2c6e5e1e6e5e1e6e5e1e6e5e1eb299309a8429787474fcf3f345444294b44a4e5e279a3f3fda5858d85757e85d5d6c2c2c161515......",
+ "............1b1b1b2817174a1e1e431c1c1f1616242a15afd457333c1dbdb7b7a7a1a14c4216e2c33dfdd843fdd843fdd843fcd843d4b639383114c1bbbba29d9d424f21a8cb53181b11221515461d1d471d1d2316161e1e1e............",
+ "..........................................1f1f1f62773197b74b2a2f1a303424181c0d21240e3132123132123132123132121d200d1b1f103135252e361ba4c7524d5d26262626..........................................",
+ "................................................181a138aa845bbe35dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe35d78923c171815................................................",
+ "......................................................14160f181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c151712......................................................",
+];
diff --git a/packages/ui/src/pixels.test.ts b/packages/ui/src/pixels.test.ts
index b8c0ee1..e178ab0 100644
--- a/packages/ui/src/pixels.test.ts
+++ b/packages/ui/src/pixels.test.ts
@@ -45,15 +45,24 @@ describe("pixelsToRuns", () => {
});
describe("masque", () => {
- it("fait 20 × 20 pixels, que des couleurs de la palette ou du transparent", () => {
+ it("fait 32 × 32 pixels, soit 16 lignes de terminal", () => {
const grid = buildMaskGrid();
- expect(grid).toHaveLength(20);
+ expect(MASK_WIDTH).toBe(32);
+ expect(grid).toHaveLength(MASK_WIDTH);
for (const row of grid) expect(row).toHaveLength(MASK_WIDTH);
- expect(pixelsToRuns(grid)).toHaveLength(10); // 10 lignes de terminal
+ expect(pixelsToRuns(grid)).toHaveLength(16);
});
- it("est symétrique gauche/droite", () => {
- for (const row of buildMaskGrid()) expect([...row].reverse()).toEqual(row);
+ it("n'a que des couleurs #rrggbb ou du transparent, avec fond transparent et image au centre", () => {
+ const grid = buildMaskGrid();
+ for (const row of grid) for (const p of row) expect(p === null || /^#[0-9a-f]{6}$/.test(p)).toBe(true);
+ expect(grid[0]![0]).toBeNull();
+ expect(grid[16]![16]).not.toBeNull();
+ });
+
+ it("n'est pas pixelisé en aplats : beaucoup de couleurs différentes (couleurs réelles)", () => {
+ const colors = new Set(buildMaskGrid().flat().filter((p) => p !== null));
+ expect(colors.size).toBeGreaterThan(30);
});
});
diff --git a/packages/ui/src/pixels.ts b/packages/ui/src/pixels.ts
index e18ebe6..ef17388 100644
--- a/packages/ui/src/pixels.ts
+++ b/packages/ui/src/pixels.ts
@@ -1,6 +1,7 @@
// Pixel-art pour le terminal, sans dépendance à React : chaque caractère « demi-bloc » (▀ ▄ █) affiche
// deux pixels superposés, ce qui donne des pixels carrés (une cellule de terminal est ~2 fois plus haute que large).
import { hslToHex } from "./colors";
+import { MASK_DATA, MASK_SIZE } from "./mask-data";
/** Couleur "#rrggbb", ou null pour un pixel transparent. */
export type Pixel = string | null;
@@ -49,45 +50,22 @@ export function pixelsToRuns(grid: Pixel[][]): Run[][] {
return lines;
}
-// ───────────── Masque (20 × 20 pixels → 10 lignes de terminal) ─────────────
+// ───────────── Masque : image affichée telle quelle (couleurs réelles, sans pixelisation) ─────────────
+// Les pixels viennent de mask-data.ts, généré depuis assets/mask.png par scripts/make-mask.py.
-const PALETTE: Record = {
- Y: "#F7C61B", // jaune (couronne, mâchoire)
- O: "#E8962A", // orange (bandeau)
- G: "#3DDC4B", // vert (contour du visage)
- K: "#111418", // noir (traits)
- W: "#F6E7D2", // crème (visage)
- R: "#E0364F", // rouge (nez, oreilles)
- B: "#8A4B2D", // brun (yeux, côtés)
-};
-
-const MASK = [
- ".........YY.........",
- "...Y.....YY.....Y...",
- "...YY...YYYY...YY...",
- "..YYY...YYYY...YYY..",
- "..YYYY.YYYYYY.YYYY..",
- "..YYYYYYYYYYYYYYYY..",
- "...OOOOOOOOOOOOOO...",
- "...GGGGGGGGGGGGGG...",
- ".BBGKKKKKKKKKKKKGBB.",
- ".BBGKYYYYYYYYYYKGBB.",
- ".BBGKWWWWWWWWWWKGBB.",
- "..RGKWBBWWWWBBWKGR..",
- "RRRGKWBBWWWWBBWKGRRR",
- "RRRGKWWWWRRWWWWKGRRR",
- "RRRGKWWWRRRRWWWKGRRR",
- "RRRGKWWWWRRWWWWKGRRR",
- ".RRGKWWYYYYYYWWKGRR.",
- "..RGKWWWYYYYWWWKGR..",
- "...GKKKKKKKKKKKKG...",
- "...GGGGGGGGGGGGGG...",
-];
-
-export const MASK_WIDTH = 20;
+/** Largeur du masque en colonnes (= nombre de pixels par ligne ; 1 pixel = 1 colonne de terminal). */
+export const MASK_WIDTH = MASK_SIZE;
+/** Décode les lignes de mask-data.ts : 6 chiffres hexadécimaux par pixel, "......" = transparent. */
export function buildMaskGrid(): Pixel[][] {
- return MASK.map((row) => [...row].map((ch) => PALETTE[ch] ?? null));
+ return MASK_DATA.map((row) => {
+ const pixels: Pixel[] = [];
+ for (let i = 0; i + 6 <= row.length; i += 6) {
+ const hex = row.slice(i, i + 6);
+ pixels.push(hex === "......" ? null : `#${hex}`);
+ }
+ return pixels;
+ });
}
// ───────────── Titre en gros caractères (police 5 × 7) ─────────────
diff --git a/packages/ui/src/store.test.ts b/packages/ui/src/store.test.ts
index ebc6f13..68f1ff7 100644
--- a/packages/ui/src/store.test.ts
+++ b/packages/ui/src/store.test.ts
@@ -120,4 +120,16 @@ describe("UIStore", () => {
expect(ko).toMatchObject({ detail: "(aucune sortie)\n[code de sortie : 1]", failed: true });
expect(big?.detail).toContain("caractères omis à l'affichage");
});
+
+ it("contexte : maximum inconnu au départ (barre masquée), puis usage et maximum renseignés", () => {
+ const s = new UIStore();
+ expect(s.getSnapshot()).toMatchObject({ contextUsed: 0, contextMax: null });
+ s.setContextMax(200_000);
+ s.setContextUsed(45_678.4);
+ expect(s.getSnapshot()).toMatchObject({ contextUsed: 45_678, contextMax: 200_000 });
+ s.setContextMax(null);
+ expect(s.getSnapshot().contextMax).toBeNull();
+ s.setContextMax(0);
+ expect(s.getSnapshot().contextMax).toBeNull();
+ });
});
diff --git a/packages/ui/src/store.ts b/packages/ui/src/store.ts
index a11ab86..d73e1c0 100644
--- a/packages/ui/src/store.ts
+++ b/packages/ui/src/store.ts
@@ -74,13 +74,17 @@ export interface UIState {
/** 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;
}
const RESULT_PREVIEW_MAX = 600;
const SHELL_PREVIEW_MAX = 4000;
export class UIStore {
- private state: UIState = { items: [], streaming: "", busy: null, prompt: null };
+ private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null };
private listeners = new Set<() => void>();
private nextId = 1;
private history: string[] = [];
@@ -136,6 +140,16 @@ export class UIStore {
this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) });
}
+ /** 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 });
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fb20674..4f227eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -111,6 +111,9 @@ importers:
ink:
specifier: ^7.0.3
version: 7.1.1(@types/react@19.3.0)(react@19.3.1)
+ markdansi:
+ specifier: ^0.2.0
+ version: 0.2.1
react:
specifier: ^19.2.0
version: 19.3.1
@@ -728,9 +731,18 @@ packages:
cpu: [x64]
os: [win32]
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@22.20.5':
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
@@ -743,6 +755,9 @@ packages:
'@types/turndown@5.0.6':
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@@ -810,6 +825,9 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -818,6 +836,9 @@ packages:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -877,6 +898,9 @@ packages:
supports-color:
optional: true
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -885,6 +909,13 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
environment@1.1.0:
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
engines: {node: '>=18'}
@@ -914,6 +945,10 @@ packages:
resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
engines: {node: '>=8'}
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -946,6 +981,10 @@ packages:
resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==}
engines: {node: '>=18'}
+ has-flag@5.0.1:
+ resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==}
+ engines: {node: '>=12'}
+
ignore@6.0.2:
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
engines: {node: '>= 4'}
@@ -995,12 +1034,143 @@ packages:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ markdansi@0.2.1:
+ resolution: {integrity: sha512-ri9ce2cmpcbL0A0FJ5a3WtIxMBRE8fPNEnSAz1CZGCfZ7MazmBEGmreOpBhXiaQZ31FXT+GYUi+86bxYpCTo8w==}
+ engines: {node: '>=22'}
+ hasBin: true
+
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
+ mdast-util-find-and-replace@3.0.3:
+ resolution: {integrity: sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==}
+
+ mdast-util-from-markdown@2.1.0:
+ resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.1:
+ resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-markdown@2.2.0:
+ resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
+ micromark-core-commonmark@2.0.4:
+ resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.2:
+ resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.1.0:
+ resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-edit-map@1.0.0:
+ resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.3:
+ resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==}
+
+ micromark@4.0.3:
+ resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==}
+
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -1152,6 +1322,14 @@ packages:
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
+ supports-color@10.2.2:
+ resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
+ engines: {node: '>=18'}
+
+ supports-hyperlinks@4.6.0:
+ resolution: {integrity: sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==}
+ engines: {node: '>=20'}
+
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -1239,6 +1417,18 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -1328,6 +1518,9 @@ packages:
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
snapshots:
'@alcalzone/ansi-tokenize@0.3.1':
@@ -1662,8 +1855,18 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.64.6':
optional: true
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
'@types/estree@1.0.9': {}
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/ms@2.1.0': {}
+
'@types/node@22.20.5':
dependencies:
undici-types: 6.21.0
@@ -1676,6 +1879,8 @@ snapshots:
'@types/turndown@5.0.6': {}
+ '@types/unist@3.0.3': {}
+
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
@@ -1739,6 +1944,8 @@ snapshots:
cac@6.7.14: {}
+ ccount@2.0.1: {}
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -1749,6 +1956,8 @@ snapshots:
chalk@5.6.2: {}
+ character-entities@2.0.2: {}
+
check-error@2.1.3: {}
chokidar@4.0.3:
@@ -1788,10 +1997,20 @@ snapshots:
dependencies:
ms: 2.1.3
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
deep-eql@5.0.2: {}
deepmerge@4.3.1: {}
+ dequal@2.0.3: {}
+
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
environment@1.1.0: {}
es-module-lexer@1.7.0: {}
@@ -1884,6 +2103,8 @@ snapshots:
escape-string-regexp@2.0.0: {}
+ escape-string-regexp@5.0.0: {}
+
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
@@ -1909,6 +2130,8 @@ snapshots:
get-east-asian-width@1.7.0: {}
+ has-flag@5.0.1: {}
+
ignore@6.0.2: {}
indent-string@5.0.0: {}
@@ -1963,12 +2186,331 @@ snapshots:
load-tsconfig@0.2.5: {}
+ longest-streak@3.1.0: {}
+
loupe@3.2.1: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
+ markdansi@0.2.1:
+ dependencies:
+ chalk: 5.6.2
+ mdast-util-from-markdown: 2.1.0
+ mdast-util-gfm: 3.1.0
+ micromark: 4.0.3
+ micromark-extension-gfm: 3.0.0
+ micromark-util-combine-extensions: 2.0.1
+ string-width: 8.3.0
+ strip-ansi: 7.2.0
+ supports-hyperlinks: 4.6.0
+ transitivePeerDependencies:
+ - supports-color
+
+ markdown-table@3.0.4: {}
+
+ mdast-util-find-and-replace@3.0.3:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.3
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.3
+ micromark-util-character: 2.1.1
+
+ mdast-util-gfm-footnote@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.1.0
+ mdast-util-to-markdown: 2.2.0
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.1.0
+ mdast-util-to-markdown: 2.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.1.0
+ mdast-util-to-markdown: 2.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.1.0
+ mdast-util-to-markdown: 2.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.1.0
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.1
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
+ mdast-util-to-markdown: 2.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-markdown@2.2.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-character: 2.1.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
+ micromark-core-commonmark@2.0.4:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.1.0
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-edit-map: 1.0.0
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.4
+ micromark-factory-space: 2.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-extension-gfm-table@2.1.2:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.3
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.2
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-factory-space@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.3
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-edit-map@1.0.0:
+ dependencies:
+ micromark-util-types: 2.0.3
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.3
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.3: {}
+
+ micromark@4.0.3:
+ dependencies:
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.4
+ micromark-factory-space: 2.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-edit-map: 1.0.0
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.3
+ transitivePeerDependencies:
+ - supports-color
+
mimic-fn@2.1.0: {}
mlly@1.8.2:
@@ -2119,6 +2661,13 @@ snapshots:
tinyglobby: 0.2.17
ts-interface-checker: 0.1.13
+ supports-color@10.2.2: {}
+
+ supports-hyperlinks@4.6.0:
+ dependencies:
+ has-flag: 5.0.1
+ supports-color: 10.2.2
+
tagged-tag@1.0.0: {}
terminal-size@4.0.1: {}
@@ -2198,6 +2747,25 @@ snapshots:
undici-types@6.21.0: {}
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
vite-node@2.1.9(@types/node@22.20.5):
dependencies:
cac: 6.7.14
@@ -2277,3 +2845,5 @@ snapshots:
ws@8.22.0: {}
yoga-layout@3.2.1: {}
+
+ zwitch@2.0.4: {}