Mise à jour locale des fichiers de travail
This commit is contained in:
parent
7a279a6065
commit
6ecf78a04c
14 changed files with 173 additions and 79 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,5 +3,3 @@ dist
|
|||
*.log
|
||||
.env
|
||||
.maia
|
||||
HOW_To_Agents.md
|
||||
resume.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/<nom>/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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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<void> {
|
|||
* 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<boolean> => {
|
||||
const compactNow = async (opts: { emergency?: boolean } = {}): Promise<boolean> => {
|
||||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
}
|
||||
const controller = new AbortController();
|
||||
ui.setInterruptHandler(() => controller.abort());
|
||||
let attempt = prompt;
|
||||
let retried = false;
|
||||
for (;;) {
|
||||
try {
|
||||
await runTurn(session, prompt, controller.signal);
|
||||
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.");
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
// Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande
|
||||
await autoCompactIfNeeded();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AgentSession, "messages" | "pendingC
|
|||
return session.messages.length > 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<CompactSummary> {
|
||||
export async function requestCompactSummary(
|
||||
session: AgentSession,
|
||||
signal?: AbortSignal,
|
||||
opts: { shrinkToolResults?: number } = {},
|
||||
): Promise<CompactSummary> {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box marginTop={1} borderStyle={DOTTED_BORDER} borderColor={color} borderLeft={false} borderRight={false} paddingX={1}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle={DOTTED_BORDER}
|
||||
borderColor="greenBright"
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
paddingX={1}
|
||||
>
|
||||
<InputFrame color="greenBright">
|
||||
<Text color={INPUT_COLOR} bold>
|
||||
{`${frame} `}
|
||||
</Text>
|
||||
|
|
@ -278,7 +280,16 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
|
|||
{value.slice(cursor + 1)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</InputFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pendant le travail de l'agent : la zone de saisie reste là, grisée (la saisie reprend à la fin du tour). */
|
||||
function WaitingInput() {
|
||||
return (
|
||||
<InputFrame color="gray">
|
||||
<Text dimColor>{"… Traitement en cours — Ctrl+C : interrompre"}</Text>
|
||||
</InputFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<Box marginTop={1}>
|
||||
<InputFrame color="yellow">
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Badge color="green">ASK</Badge>
|
||||
</Box>
|
||||
<Text>
|
||||
{prompt.message} <Text dimColor>(o/n)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</InputFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -356,7 +368,6 @@ function PromptView({ prompt, state }: { prompt: PromptState; state: UIState })
|
|||
return (
|
||||
<Box flexDirection="column">
|
||||
<InputPrompt prompt={prompt} />
|
||||
{state.contextMax !== null ? <ContextBar used={state.contextUsed} max={state.contextMax} /> : null}
|
||||
</Box>
|
||||
);
|
||||
case "select":
|
||||
|
|
@ -394,12 +405,13 @@ export function App({ store }: { store: UIStore }) {
|
|||
<Text>{state.streaming}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{state.busy ? (
|
||||
<Box marginTop={1}>
|
||||
<Spinner label={state.busy} />
|
||||
{/* Ligne d'état réservée : toujours présente (vide hors traitement) pour que la zone de saisie ne bouge jamais */}
|
||||
<Box marginTop={1} height={1}>
|
||||
{state.busy ? <Spinner label={state.busy} /> : <Text> </Text>}
|
||||
</Box>
|
||||
) : null}
|
||||
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : null}
|
||||
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : state.closing ? null : <WaitingInput />}
|
||||
{/* Toujours affichée, tout en bas : saisie, itérations, outils, confirmations, listes (taille maximale inconnue : tokens seuls) */}
|
||||
<ContextBar used={state.contextUsed} max={state.contextMax} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box justifyContent="flex-end" paddingRight={1}>
|
||||
<Text dimColor>{`${formatTokens(used)} tokens · taille maximale inconnue`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
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}% `;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<UIState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
for (const l of this.listeners) l();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,2 @@
|
|||
packages:
|
||||
- "packages/*"
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
|
|
|||
Loading…
Reference in a new issue