diff --git a/.gitignore b/.gitignore index 36c4f6c..2be012b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,3 @@ dist *.log .env .maia -HOW_To_Agents.md -resume.md diff --git a/README.md b/README.md index d258c3d..bfc2ca1 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,8 @@ L'appel est **bloquant** (pas de tâche de fond). Ctrl+C l'annule sans rien modi Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `startCompacted`), `packages/agent/src/init.ts` (`startContext`), orchestration dans `packages/cli/src/index.ts`. +**Contexte refusé par le serveur** : si LiteLLM répond « Input is too long » (ou équivalent), MAIA compacte automatiquement la conversation (résultats d'outils tronqués à 1 500 caractères dans la demande de résumé, pour qu'elle tienne), puis reprend la demande une seule fois. Si le compactage échoue aussi, l'erreur d'origine est affichée (utilisez alors `/new`). Les sous-agents n'ont pas ce mécanisme. + ## Sous-agents (`./.maia/agents`) MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle. @@ -423,3 +425,8 @@ Journal des évolutions, dans l'ordre : 31. **Coupure à 4096 tokens diagnostiquée** (log `MAIA_DEBUG_LLM`) : détection d'une sortie tombant sur une limite usuelle avec un appel d'outil, message d'erreur de `Write` qui conseille d'écrire en plusieurs morceaux. 32. **Outil Bash via `runShell`** : timeout et Ctrl+C arrêtent toute l'arborescence de processus (groupe de processus, SIGTERM puis SIGKILL) ; stdout et stderr fusionnés dans l'ordre d'arrivée, dernière ligne `[code de sortie : N]` ou état (délai dépassé, interrompu). 33. **Sous-agents** : définitions `./.maia/agents//AGENT.md` et `~/.maia/agents` (en-tête YAML : name, description, model, tools, permissions, effort, skills, omitMd, trace ; sections Instructions system / Missions / Output), outil `Agent`, processus autonome (`fork` + IPC), conversation vide, résumé final seul renvoyé au parent, confirmations relayées à l'interface principale, notification INFO de fin, trace optionnelle `session_…-agent_1.json`, commande `/agents`. +34. **Compactage sur « Input is too long »** : réponse du serveur reconnue (`isContextTooLongError`), compactage d'urgence (résultats d'outils tronqués dans la demande de résumé), reprise de la demande une fois. +35. **Barre de contexte pendant le travail de l'agent** : taille recomptée à chaque itération (après la demande, chaque réponse du LLM et chaque série de résultats d'outils) et barre affichée pendant les itérations, plus seulement à la saisie. +36. **Barre de contexte toujours affichée** : en bas de l'écran en permanence (saisie, itérations, outils, confirmations, listes) ; si la taille maximale du contexte est inconnue, seul le nombre de tokens utilisés est montré. +37. **Zone de saisie toujours visible** : grisée pendant le travail de l'agent (« Traitement en cours — Ctrl+C : interrompre »), et c'est elle qui porte les demandes d'autorisation (`ASK … (o/n)`), dans le même cadre en pointillés. +38. **Ligne d'état réservée** au-dessus de la zone de saisie (« En réflexion… », outils en cours) : toujours présente, vide hors traitement, pour que la zone de saisie ne remonte ni ne descende. diff --git a/SPECS_TECH.md b/SPECS_TECH.md index f7b93dc..543e41b 100644 --- a/SPECS_TECH.md +++ b/SPECS_TECH.md @@ -426,6 +426,8 @@ Après le choix d'un modèle (démarrage, `/model`), MAIA pose au LLM la questio Sous la zone de saisie, en bas à droite : carrés ■ de gauche à droite, **1 carré = 10 000 tokens** (200 000 → 20 carrés), libellé `45.7k / 200k · 23%`. Au moins 1 carré rempli dès que le contexte n'est pas vide. Couleurs : vert, **jaune dès 60 %**, **rouge dès 85 %** ; carrés vides gris. Si la barre dépasse la largeur du terminal, l'échelle grandit par paliers de 10 000 et est indiquée (`1■ = 20k`). Format des nombres : `950`, `12.3k`, `200k`, `1.05M`. +Pendant un tour, `runTurn` appelle `onContextUsage` après l'ajout de la demande, après chaque réponse du LLM et après chaque série de résultats d'outils ; la barre est affichée en permanence en bas de l'écran (saisie, itérations, outils, confirmations, listes) ; taille maximale inconnue : « N tokens · taille maximale inconnue », sans carrés. + ## 14. Compactage (`core/src/compact.ts` + `cli/src/index.ts`) ### 14.1 Principe @@ -469,6 +471,10 @@ En cas d'erreur ou de Ctrl+C **avant** le remplacement, la conversation est inch Le fichier d'avant compactage reste intact. Le fichier `xxx__n` contient `startContext` (le résumé) et la suite de la conversation ; il est écrit **immédiatement** (une session avec `startContext` n'est pas « vide »). `/load xxx__n` reconstruit l'agent avec ce contexte de départ et affiche le résumé. +### 14.5 Contexte refusé par le serveur + +`isContextTooLongError(err)` reconnaît les erreurs « input is too long », « prompt is too long », « context_length_exceeded », « maximum context length »… Dans la boucle de `cli/src/index.ts`, hors interruption et une seule fois par demande : avertissement, `compactNow({ emergency: true })` (la demande de résumé utilise `shrinkToolResults(messages, EMERGENCY_TOOL_RESULT_CHARS = 1500)` pour tenir dans la fenêtre ; la session d'origine est sauvegardée avant), puis la demande est relancée avec une consigne de reprise sur la conversation compactée. Si le compactage échoue, l'erreur d'origine est affichée. Non couvert : sous-agents. + ## 15. Interface (`packages/ui`) ### 15.1 Architecture @@ -506,6 +512,8 @@ Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de pale | Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : déplacement · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter | | Pendant un tour | Ctrl+C : interrompt et restaure la conversation d'avant la demande | + +La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie active, grisée « Traitement en cours » pendant un tour (masquée à l'arrêt du programme), ou demande d'autorisation `ASK … (o/n)` (réponse `o`/`y` ou `n`/Échap/Ctrl+C). Les listes de choix (`/model`, `/load`) remplacent la zone le temps du choix. Au-dessus, une ligne d'état est toujours réservée (spinner « En réflexion », outils en cours ; vide sinon) : la zone de saisie garde la même position. La barre de contexte est toujours en dessous. ## 16. Fichiers et variables | Chemin / variable | Rôle | diff --git a/generated_page.md b/generated_page.md deleted file mode 100644 index 1ade067..0000000 --- a/generated_page.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 0492ce2..58340ae 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,7 +8,9 @@ import { createSession, ensureMaiaHome, findSkill, + EMERGENCY_TOOL_RESULT_CHARS, isCompactable, + isContextTooLongError, normalizeSessionName, parseShellInvocation, resolveFileReferences, @@ -327,7 +329,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { * 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ; * 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée. */ - const compactNow = async (): Promise => { + const compactNow = async (opts: { emergency?: boolean } = {}): Promise => { if (!isCompactable(session)) { ui.info("Rien à compacter : la conversation est vide."); return false; @@ -337,7 +339,12 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { ui.setBusy("Compactage de la conversation…"); try { saveSession(); // la session d'origine reste complète dans son fichier - const { summary, truncated } = await requestCompactSummary(session, controller.signal); + const { summary, truncated } = await requestCompactSummary( + session, + controller.signal, + // Urgence (le serveur a refusé le contexte) : la demande de résumé doit elle-même tenir dans la fenêtre + opts.emergency ? { shrinkToolResults: EMERGENCY_TOOL_RESULT_CHARS } : {}, + ); const fresh = await startSession(modelSpec, { startContext: summary, quiet: true }); session = fresh; let target = ""; @@ -583,7 +590,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { await autoCompactIfNeeded(); // Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation - const mark = session.messages.length; + let mark = session.messages.length; const contextBackup = [...session.pendingContext]; // Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que @@ -601,20 +608,39 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { } const controller = new AbortController(); ui.setInterruptHandler(() => controller.abort()); - try { - await runTurn(session, prompt, controller.signal); - } catch (err) { - 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 - showContext(session); - ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande."); - } else { + let attempt = prompt; + let retried = false; + for (;;) { + try { + await runTurn(session, attempt, controller.signal); + break; + } catch (err) { + 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 + showContext(session); + ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande."); + break; + } + if (!retried && isContextTooLongError(err)) { + // Le serveur refuse le contexte (notre comptage local sous-estime) : compactage, puis reprise de la demande une seule fois + retried = true; + ui.warn("Le serveur indique que le contexte est trop long : compactage automatique, puis reprise de la demande."); + const compacted = await compactNow({ emergency: true }); + ui.setInterruptHandler(() => controller.abort()); // compactNow a retiré le gestionnaire Ctrl+C + if (compacted) { + mark = 0; // nouvelle conversation (résumé comme contexte de départ) + attempt = + "Le contexte a été compacté parce qu'il dépassait la fenêtre du modèle. Le résumé de départ indique ce qui est déjà fait. " + + `Reprends là où tu t'es arrêté et termine la demande suivante :\n\n${prompt}`; + continue; + } + } ui.error((err as Error).message); + break; } - } finally { - ui.setInterruptHandler(null); } + ui.setInterruptHandler(null); // Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande await autoCompactIfNeeded(); } diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index b915bc4..df0cfdb 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -182,11 +182,12 @@ describe("taille du contexte", () => { cwd: process.cwd(), }); await runTurn(session, "go"); - expect(seen).toHaveLength(2); // une mise à jour par réponse du LLM (appel d'outil, puis réponse finale) + // mise à jour à chaque étape : demande, réponse avec appel d'outil, résultat de l'outil, réponse finale + expect(seen).toHaveLength(4); expect(seen[0]).toBeGreaterThan(0); - expect(seen[1]).toBeGreaterThan(seen[0]!); - expect(seen[1]).toBe(sessionContextTokens(session)); - expect(seen[1]).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée + for (let i = 1; i < seen.length; i++) expect(seen[i]!).toBeGreaterThan(seen[i - 1]!); + expect(seen.at(-1)).toBe(sessionContextTokens(session)); + expect(seen.at(-1)!).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée }); it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => { diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 18738de..ea48a6c 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -75,6 +75,7 @@ export async function runTurn( const context = session.pendingContext.splice(0); const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText; session.messages.push({ role: "user", content: [{ type: "text", text }] }); + ui.onContextUsage?.(sessionContextTokens(session)); // la demande (et le contexte joint) occupe déjà des tokens for (let i = 0; i < maxIterations; i++) { const assistant: ContentBlock[] = []; @@ -138,6 +139,7 @@ export async function runTurn( // 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins const results = await runToolCalls(session, calls, signal); session.messages.push({ role: "user", content: results }); + ui.onContextUsage?.(sessionContextTokens(session)); // les résultats d'outils pèsent avant l'itération suivante } session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true); return "max_iterations"; diff --git a/packages/core/src/compact.test.ts b/packages/core/src/compact.test.ts index 3f52cb3..b2925d2 100644 --- a/packages/core/src/compact.test.ts +++ b/packages/core/src/compact.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createSession } from "./agent"; -import { COMPACT_PROMPT, compactThreshold, isCompactable, requestCompactSummary, shouldCompact } from "./compact"; +import { COMPACT_PROMPT, compactThreshold, isCompactable, isContextTooLongError, requestCompactSummary, shouldCompact, shrinkToolResults } from "./compact"; import { SessionManager, SessionStore, ensureMaiaHome, nextCompactedName } from "./sessions"; import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types"; @@ -190,3 +190,37 @@ describe("persistance d'une session compactée", () => { expect(mgr.isEmpty({ messages: [], pendingContext: [], startContext: "r" })).toBe(false); }); }); + +describe("contexte refusé par le serveur", () => { + it("reconnaît les messages « entrée trop longue » des principaux serveurs", () => { + const msg = 'Échec (HTTP 400). {"error":{"code":"UPSTREAM_LLM_ERROR","message":"{\\"message\\":\\"Input is too long for requested model.\\"}"}}'; + expect(isContextTooLongError(new Error(msg))).toBe(true); + expect(isContextTooLongError(new Error("prompt is too long: 250000 tokens > 200000 maximum"))).toBe(true); + expect(isContextTooLongError(new Error("This model's maximum context length is 128000 tokens"))).toBe(true); + expect(isContextTooLongError(new Error("context_length_exceeded"))).toBe(true); + expect(isContextTooLongError(new Error("HTTP 401 Unauthorized"))).toBe(false); + }); + it("shrinkToolResults : tronque les gros résultats d'outils sans toucher aux originaux", () => { + const big = "x".repeat(5000); + const original: Message[] = [ + { role: "user", content: [{ type: "tool_result", toolUseId: "1", content: big }, { type: "text", text: "garde" }] }, + ]; + const out = shrinkToolResults(original, 100); + const block = out[0]!.content[0] as { content: string }; + expect(block.content.length).toBeLessThan(200); + expect(block.content).toContain("tronqué"); + expect(out[0]!.content[1]).toEqual({ type: "text", text: "garde" }); + expect((original[0]!.content[0] as { content: string }).content).toBe(big); + }); + it("requestCompactSummary en urgence : la requête de résumé contient des résultats tronqués", async () => { + const seen: ChatRequest[] = []; + const s = sessionWith(providerReplying([{ type: "text_delta", text: "## R" }, { type: "done", stopReason: "end_turn" }], seen), [ + user("q"), + { role: "assistant", content: [{ type: "tool_use", id: "1", name: "Read", input: {} }] }, + { role: "user", content: [{ type: "tool_result", toolUseId: "1", content: "y".repeat(9000) }] }, + ]); + await requestCompactSummary(s, undefined, { shrinkToolResults: 500 }); + expect(JSON.stringify(seen[0]!.messages).length).toBeLessThan(3000); + expect((s.messages[2]!.content[0] as { content: string }).content.length).toBe(9000); + }); +}); diff --git a/packages/core/src/compact.ts b/packages/core/src/compact.ts index 3f11ee0..174a311 100644 --- a/packages/core/src/compact.ts +++ b/packages/core/src/compact.ts @@ -1,5 +1,5 @@ import type { AgentSession } from "./agent"; -import type { Message } from "./types"; +import type { ContentBlock, Message } from "./types"; // Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ // est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ; @@ -51,15 +51,41 @@ export function isCompactable(session: Pick 0 || session.pendingContext.length > 0; } +/** Réponse du serveur « entrée trop longue » (HTTP 400 : Anthropic/Bedrock, OpenAI, LiteLLM…) : le contexte dépasse la fenêtre du modèle. */ +export function isContextTooLongError(err: unknown): boolean { + const text = err instanceof Error ? err.message : String(err); + return /input is too long|prompt is too long|too long for requested model|context[_ ]length[_ ]exceeded|maximum context length|exceeds? the (maximum )?context|context window/i.test(text); +} + +/** Nombre de caractères conservés par résultat d'outil quand le compactage est lancé en urgence (contexte déjà saturé). */ +export const EMERGENCY_TOOL_RESULT_CHARS = 1500; + +/** Copie des messages dont les résultats d'outils sont tronqués à `max` caractères (les originaux ne sont pas modifiés). */ +export function shrinkToolResults(messages: Message[], max: number): Message[] { + return messages.map((m) => ({ + ...m, + content: m.content.map((b): ContentBlock => + b.type === "tool_result" && b.content.length > max + ? { ...b, content: `${b.content.slice(0, max)}… (tronqué pour le compactage)` } + : b, + ), + })); +} + /** * Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée : * c'est à l'appelant de bâtir la nouvelle session une fois le résumé obtenu (en cas d'erreur ou d'annulation, rien n'est perdu). * Lève une erreur si le résumé est vide. */ -export async function requestCompactSummary(session: AgentSession, signal?: AbortSignal): Promise { +export async function requestCompactSummary( + session: AgentSession, + signal?: AbortSignal, + opts: { shrinkToolResults?: number } = {}, +): Promise { // Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer const request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n"); - const messages: Message[] = [...session.messages]; + const messages: Message[] = + opts.shrinkToolResults !== undefined ? shrinkToolResults(session.messages, opts.shrinkToolResults) : [...session.messages]; const last = messages.at(-1); if (last?.role === "user") { // L'alternance user/assistant doit être respectée : on complète le dernier message diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 5ecfea8..d874efc 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -184,6 +184,15 @@ const DOTTED_BORDER = { right: "┊", } as const; +/** Zone de saisie (cadre en pointillés) : toujours visible ; sert aussi aux demandes d'autorisation et à l'attente. */ +function InputFrame({ color, children }: { color: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} + const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter"; function InputPrompt({ prompt }: { prompt: InputPromptState }) { @@ -254,14 +263,7 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) { const { value, cursor } = view; const at = value[cursor] ?? " "; return ( - + {`${frame} `} @@ -278,7 +280,16 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) { {value.slice(cursor + 1)} )} - + + ); +} + +/** Pendant le travail de l'agent : la zone de saisie reste là, grisée (la saisie reprend à la fin du tour). */ +function WaitingInput() { + return ( + + {"… Traitement en cours — Ctrl+C : interrompre"} + ); } @@ -338,15 +349,16 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) { else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false); // Entrée seule est ignorée : une action sensible exige une réponse explicite. }); + // La demande d'autorisation prend la place de la zone de saisie return ( - + ASK {prompt.message} (o/n) - + ); } @@ -356,7 +368,6 @@ function PromptView({ prompt, state }: { prompt: PromptState; state: UIState }) return ( - {state.contextMax !== null ? : null} ); case "select": @@ -394,12 +405,13 @@ export function App({ store }: { store: UIStore }) { {state.streaming} ) : null} - {state.busy ? ( - - - - ) : null} - {state.prompt ? : null} + {/* Ligne d'état réservée : toujours présente (vide hors traitement) pour que la zone de saisie ne bouge jamais */} + + {state.busy ? : } + + {state.prompt ? : state.closing ? null : } + {/* Toujours affichée, tout en bas : saisie, itérations, outils, confirmations, listes (taille maximale inconnue : tokens seuls) */} + ); } diff --git a/packages/ui/src/context-bar.tsx b/packages/ui/src/context-bar.tsx index bbd5d27..d96cbc1 100644 --- a/packages/ui/src/context-bar.tsx +++ b/packages/ui/src/context-bar.tsx @@ -6,8 +6,16 @@ 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 }) { +export function ContextBar({ used, max }: { used: number; max: number | null }) { const { stdout } = useStdout(); + if (max === null) { + // Taille maximale inconnue : pas de carrés, seulement le contexte utilisé + return ( + + {`${formatTokens(used)} tokens · taille maximale inconnue`} + + ); + } 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}% `; diff --git a/packages/ui/src/ink-ui.tsx b/packages/ui/src/ink-ui.tsx index 7f3f954..c39127f 100644 --- a/packages/ui/src/ink-ui.tsx +++ b/packages/ui/src/ink-ui.tsx @@ -24,6 +24,7 @@ export class InkUI implements AgentUI { const app = this.app; if (!app) return; this.app = null; + this.store.setClosing(); await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages const exited = app.waitUntilExit(); // à créer AVANT unmount() app.unmount(); diff --git a/packages/ui/src/store.ts b/packages/ui/src/store.ts index d73e1c0..ed749d6 100644 --- a/packages/ui/src/store.ts +++ b/packages/ui/src/store.ts @@ -78,13 +78,15 @@ export interface UIState { 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; + /** Arrêt du programme en cours : la zone de saisie grisée n'est plus affichée. */ + closing: boolean; } const RESULT_PREVIEW_MAX = 600; const SHELL_PREVIEW_MAX = 4000; export class UIStore { - private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null }; + private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null, closing: false }; private listeners = new Set<() => void>(); private nextId = 1; private history: string[] = []; @@ -99,6 +101,10 @@ export class UIStore { }; }; + setClosing(): void { + this.commit({ closing: true }); + } + private commit(patch: Partial): void { this.state = { ...this.state, ...patch }; for (const l of this.listeners) l(); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7e24990..dee51e9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,2 @@ packages: - "packages/*" -allowBuilds: - esbuild: true