diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index daa703f..38393b0 100644 Binary files a/cmd/jean/resource_windows_amd64.syso and b/cmd/jean/resource_windows_amd64.syso differ diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 2da7299..067b298 100644 Binary files a/cmd/jean/resource_windows_arm64.syso and b/cmd/jean/resource_windows_arm64.syso differ diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 3834654..dc6b939 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 8, + "Patch": 9, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 8, + "Patch": 9, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.8", + "ProductVersion": "0.6.9", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/chat_agent.go b/internal/jean/chat_agent.go index a8adcad..fdbaccd 100644 --- a/internal/jean/chat_agent.go +++ b/internal/jean/chat_agent.go @@ -55,7 +55,7 @@ func cmdAgent(args []string) error { if err := setAgentEnabled(true); err != nil { return err } - fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet (bash) et de sa mémoire") + fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet (" + shellName() + "), de l'écriture de fichiers et de sa mémoire") case "off": if err := setAgentEnabled(false); err != nil { return err @@ -67,7 +67,7 @@ func cmdAgent(args []string) error { state = green("on") } fmt.Printf("%s état: %s\n", cyan("Mode agent"), state) - fmt.Printf(" outils : bash (timeout %ds, max %ds) + mémoire (mem_search/mem_read/mem_add/mem_edit)\n", toolDefaultTimeout, toolMaxTimeout) + fmt.Printf(" outils : %s (timeout %ds, max %ds) + write/edit + mémoire (mem_search/mem_read/mem_add/mem_edit)\n", shellName(), toolDefaultTimeout, toolMaxTimeout) mem := MemList() if len(mem) == 0 { fmt.Printf(" mémoire : aucune page — crée %s/.md\n", memoryDir()) diff --git a/internal/jean/chat_cmd.go b/internal/jean/chat_cmd.go index 2dfc54f..a6cb6dd 100644 --- a/internal/jean/chat_cmd.go +++ b/internal/jean/chat_cmd.go @@ -106,6 +106,9 @@ func cmdChat(args []string) error { case "bash": icon = "⚙️" verb = "exécution" + case "write": + icon = "📄" + verb = "écriture" case "edit": icon = "✏️" verb = "édition" diff --git a/internal/jean/chat_conversation.go b/internal/jean/chat_conversation.go index 6c5a383..9a2420b 100644 --- a/internal/jean/chat_conversation.go +++ b/internal/jean/chat_conversation.go @@ -339,6 +339,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa "name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label, "result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing, } + // Corps en cours de frappe : transitoire (l'état final est le diff), on + // ne l'ajoute que quand il est là pour ne pas gonfler chaque événement. + if ev.ToolUsed.Body != "" { + tu["body"] = ev.ToolUsed.Body + } // Lignes +/- d'une écriture (edit / mémoire) : persistées avec le reste // pour que le diff soit encore là après un rafraîchissement. if len(ev.ToolUsed.Diff) > 0 { diff --git a/internal/jean/chat_tools.go b/internal/jean/chat_tools.go index dbd72b9..56d52e1 100644 --- a/internal/jean/chat_tools.go +++ b/internal/jean/chat_tools.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "os/user" + "path/filepath" "runtime" "strconv" "strings" @@ -47,7 +48,8 @@ func baseSystemPrompt(caps Caps) string { } b.WriteString("\n\nTools:\n") if caps.Agent { - b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts).\n") + b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts). The shell is " + shellName() + ": use its syntax.\n") + b.WriteString("- write — create or rewrite a file with exact content.\n") b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n") } if hasMem { @@ -63,7 +65,10 @@ func baseSystemPrompt(caps Caps) string { b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n") } if caps.Agent { - b.WriteString("\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n") + // Règle courte mais indispensable : sans elle le modèle fabrique ses scripts + // avec echo/python -c, ce que cmd.exe massacre (guillemets imbriqués). + b.WriteString("\nTo create or rewrite a file, always use write (or edit to patch one) — never echo, cat, python -c or Set-Content through the shell; quoting breaks. Write the script with write, then run it with bash.\n") + b.WriteString("For anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n") if caps.Mem == MemAlways { b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n") } @@ -164,6 +169,48 @@ func runShell(command string, timeoutSec int) string { return strings.Join(parts, "\n\n") } +// shellName is the shell runShell actually spawns on this platform. The model is +// told this explicitly: advertising the tool as "bash" on Windows made it emit +// bash quoting into cmd.exe, which mangles it (unterminated string literals, and +// stray "Commande ECHO activée." landing inside generated files). +func shellName() string { + if runtime.GOOS == "windows" { + return "cmd.exe" + } + return "bash" +} + +// fileWrite writes content to path verbatim, creating parent directories and +// replacing any existing file. This is the escape hatch from shell quoting: a +// model with only a shell has to build files with echo/python -c, which is +// unreliable everywhere and outright broken on cmd.exe. +func fileWrite(path, content string) string { + if strings.TrimSpace(path) == "" { + return "[erreur] chemin vide" + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "[erreur] " + err.Error() + } + } + // Préserve les permissions d'origine quand le fichier existe déjà (un script + // 0755 réécrit doit rester exécutable). + mode := os.FileMode(0o644) + existed := false + if fi, err := os.Stat(path); err == nil { + mode = fi.Mode() + existed = true + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + return "[erreur] " + err.Error() + } + verb := "créé" + if existed { + verb = "réécrit" + } + return fmt.Sprintf("[ok] %s %s (%d octets)", path, verb, len(content)) +} + // fileEdit applies a single exact-text replacement to a file on disk: oldText // must appear EXACTLY once (otherwise it errors), so the model can patch a file // without rewriting it whole. Returns a short status string for the tool result. diff --git a/internal/jean/chat_tools_write_test.go b/internal/jean/chat_tools_write_test.go new file mode 100644 index 0000000..cab2195 --- /dev/null +++ b/internal/jean/chat_tools_write_test.go @@ -0,0 +1,50 @@ +package jean + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Le contenu doit arriver sur le disque à l'octet près : c'est tout l'intérêt de +// l'outil write (les guillemets imbriqués d'un script Python ne survivaient pas +// au passage par le shell). +func TestFileWriteVerbatim(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "kpi.py") + content := "import sqlite3\nq = \"SELECT * FROM t WHERE n = 'a'\"\nprint(q)\n" + + res := fileWrite(path, content) + if !strings.HasPrefix(res, "[ok]") { + t.Fatalf("write a échoué: %s", res) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("relecture: %v", err) + } + if string(got) != content { + t.Fatalf("contenu altéré:\n%q\nattendu:\n%q", got, content) + } +} + +func TestFileWriteOverwrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "a.txt") + if res := fileWrite(path, "ancien"); !strings.HasPrefix(res, "[ok]") { + t.Fatalf("premier write: %s", res) + } + res := fileWrite(path, "nouveau") + if !strings.Contains(res, "réécrit") { + t.Fatalf("attendu « réécrit », obtenu: %s", res) + } + got, _ := os.ReadFile(path) + if string(got) != "nouveau" { + t.Fatalf("contenu = %q", got) + } +} + +func TestFileWriteRejectsEmptyPath(t *testing.T) { + if res := fileWrite(" ", "x"); !strings.HasPrefix(res, "[erreur]") { + t.Fatalf("attendu une erreur, obtenu: %s", res) + } +} diff --git a/internal/jean/llm_client.go b/internal/jean/llm_client.go index b1734a7..c4880cb 100644 --- a/internal/jean/llm_client.go +++ b/internal/jean/llm_client.go @@ -140,12 +140,30 @@ func editTool() Tool { } } +func writeTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "write", + Description: "Write a file on disk with the exact content given (creates it, or replaces it whole; parent directories are created). ALWAYS use this to create a script or any text file — NEVER build a file through the shell with echo, cat, python -c or Set-Content: the shell mangles quotes and breaks the file. Content is written verbatim, no escaping needed.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]any{"type": "string", "description": "Path of the file to write"}, + "content": map[string]any{"type": "string", "description": "Full content of the file, verbatim"}, + }, + "required": []string{"file", "content"}, + }, + }, + } +} + func bashTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "bash", - Description: "Execute a shell command (bash) on this machine and return stdout, stderr and the exit code. For inspecting the system, running scripts, reading/editing files, reading logs. Avoid destructive commands unless explicitly asked.", + Description: "Execute a shell command on this machine (" + shellName() + ") and return stdout, stderr and the exit code. Use " + shellName() + " syntax, not another shell's. For inspecting the system, running scripts, reading files, reading logs. To CREATE or REWRITE a file, use the write tool instead — never echo/cat/python -c. Avoid destructive commands unless explicitly asked.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -223,7 +241,7 @@ func InjectSkills(msgs []Message, caps Caps) []Message { func EnabledTools(caps Caps) []Tool { tools := []Tool{} if caps.Agent { - tools = append(tools, bashTool(), editTool()) + tools = append(tools, bashTool(), writeTool(), editTool()) } // Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès // que le mode mémoire n'est pas « off » (que l'agent soit actif ou non). @@ -278,11 +296,29 @@ type ToolUsedEvent struct { Result string // tool output (stdout/stderr/exit for run_shell, skill body for read_skill) Done bool // false = call announced (command only); true = result is ready Typing bool // true = command still being written (partial), no spinner yet + // Body : contenu en cours d'écriture par un outil d'écriture (write/edit, + // mem_add/mem_edit), diffusé ligne à ligne pendant que le modèle le tape, + // pour que la bulle se remplisse en direct au lieu de rester figée puis de + // s'ouvrir d'un coup. Transitoire : seul Diff (état final) est rejoué. + Body string // Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose // (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-). Diff []DiffLine } +// writeBodyKey returns the argument holding the text an écriture tool is about +// to commit — the part worth showing live in the bubble — or "" for tools that +// have no such body (bash, lectures, recherches). +func writeBodyKey(tool string) string { + switch tool { + case "write", "mem_add": + return "content" + case "edit", "mem_edit": + return "new" + } + return "" +} + // previewArg pulls the (possibly incomplete) string value of key out of a // streaming tool-call arguments JSON, so the UI can show the command being // typed live. Best-effort: it tolerates a truncated tail and basic escapes. @@ -522,7 +558,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ // pour que les consommateurs (terminal, web) aient toujours tout. var stats StatsEvent - lastPreview := "" // last command preview emitted (to stream the typing) + lastPreview := "" // last command preview emitted (to stream the typing) + lastBodyLines := -1 // lignes déjà diffusées du corps en cours d'écriture // Per-completion reasoning-split state (see reasoningOn comment above). sawReasoningField := false thinkOpen := reasoningOn @@ -608,14 +645,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps switch cur.Function.Name { case "mem_search", "web_search": key = "query" - case "mem_read", "mem_add", "mem_edit", "edit": + case "mem_read", "mem_add", "mem_edit", "edit", "write": key = "file" case "web_open", "web_read", "web_grep": key = "url" } - if p := previewArg(cur.Function.Arguments, key); p != "" && p != lastPreview { - lastPreview = p - if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: p, Typing: true}}) { + p := previewArg(cur.Function.Arguments, key) + // Corps en cours de frappe pour les outils d'écriture : on le diffuse + // à la LIGNE, pas au token. Un événement par token republierait tout le + // contenu à chaque fois (coût quadratique, et c'est ce flot qui saturait + // le rendu mobile) ; à la ligne, le nombre d'événements est celui du + // fichier et l'animation reste fluide. + body := "" + if bk := writeBodyKey(cur.Function.Name); bk != "" { + body = previewArg(cur.Function.Arguments, bk) + } + grew := body != "" && strings.Count(body, "\n") > lastBodyLines + if (p != "" && p != lastPreview) || grew { + if p != "" { + lastPreview = p + } + if grew { + lastBodyLines = strings.Count(body, "\n") + } + if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) { aborted = true break } @@ -744,7 +797,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps switch tc.Function.Name { case "mem_search", "web_search": label, _ = args["query"].(string) - case "mem_read", "mem_add", "mem_edit", "edit": + case "mem_read", "mem_add", "mem_edit", "edit", "write": label, _ = args["file"].(string) case "bash": label, _ = args["command"].(string) @@ -827,6 +880,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps result = fmt.Sprintf("[ok] page '%s' modifiée", label) diff = lineDiff(oldText, newText) } + case "write": + content, _ := args["content"].(string) + result = fileWrite(label, content) + if !strings.HasPrefix(result, "[erreur]") { + diff = addedDiff(content) + } case "edit": oldText, _ := args["old"].(string) newText, _ := args["new"].(string) diff --git a/internal/jean/run.go b/internal/jean/run.go index 9441879..11b8d20 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.8" +const Version = "0.6.9" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index a6539bc..59ae378 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -273,6 +273,13 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)} .msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)} .msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)} +/* Écriture en direct : le bloc se remplit ligne à ligne pendant que le modèle + tape. Seule la dernière ligne est en fondu (.fresh) — animer tout le bloc à + chaque nouvelle ligne le ferait clignoter. Hauteur bornée + scroll auto en + bas (voir 08-chat-render.js) pour qu'un long script ne pousse pas le fil. */ +.msg.tool pre.diff.live .dl.fresh{animation:difffresh .18s ease-out} +@keyframes difffresh{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}} +@media (prefers-reduced-motion:reduce){.msg.tool pre.diff.live .dl.fresh{animation:none}} .diff-count{margin-left:8px;letter-spacing:0;text-transform:none} .diff-count .a{color:var(--add)} .diff-count .d{color:var(--del)} @@ -881,7 +888,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
Mode agent -
+
Mémoire?
@@ -2621,6 +2628,17 @@ function collapseInstant(el){ requestAnimationFrame(()=>{ bw.style.transition=''; }); } function setLabel(el, text){ el.querySelector('.label').textContent = text; } +// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible +// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture +// survit au repli, donc on le met là plutôt que dans le corps seul. +function setLabelCounts(el, add, del){ + const lab=el.querySelector('.label'); + const cnt=document.createElement('span'); cnt.className='diff-count'; + if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add})); + if(add && del) cnt.appendChild(document.createTextNode(' ')); + if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); + lab.appendChild(cnt); +} // Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN // sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait // avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée @@ -2649,6 +2667,7 @@ function renderToolMsg(el, tu){ // (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire. const META = { bash: {lbl:'terminal', head:'commande'}, + write: {lbl:'fichier', head:'écriture'}, edit: {lbl:'édition', head:'édition'}, web_search: {lbl:'recherche', head:'recherche web'}, web_open: {lbl:'page web', head:'ouverture'}, @@ -2669,6 +2688,12 @@ function renderToolMsg(el, tu){ // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; } setLabel(el, lbl); + // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe) + // reporté sur l'étiquette, pour rester lisible bulle repliée. + let add=0, del=0; + if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } + else if(tu.body){ add=tu.body.split('\n').length; } + if(add||del) setLabelCounts(el, add, del); const body=bodyOf(el); body.innerHTML=''; const head=document.createElement('div'); head.className='tool-head'; head.textContent = meta.head; @@ -2679,16 +2704,37 @@ function renderToolMsg(el, tu){ if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); } pre.appendChild(code); body.appendChild(pre); } + // Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à + // ligne, dans la même forme que le diff final, pour que la bulle se remplisse + // sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la + // dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement + // ferait clignoter le bloc entier. + if(tu.body && !(tu.diff && tu.diff.length)){ + const lines=tu.body.split('\n'); + const sub=document.createElement('div'); sub.className='tool-sub'; + sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée) + body.appendChild(sub); + const pre=document.createElement('pre'); pre.className='diff live'; + lines.forEach((t,i)=>{ + const ln=document.createElement('span'); + ln.className='dl add'+(i===lines.length-1?' fresh':''); + ln.textContent='+ '+t; + if(i===lines.length-1 && tu.typing){ + const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; + ln.appendChild(car); + } + pre.appendChild(ln); + }); + body.appendChild(pre); + // Le bloc est re-créé à chaque événement : on le recale en bas pour suivre + // la ligne en cours (max-height côté CSS l'empêche de pousser le fil). + pre.scrollTop = pre.scrollHeight; + } // Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert, // retirées en rouge, contexte en gris — comme un diff de terminal. if(tu.diff && tu.diff.length){ const sub=document.createElement('div'); sub.className='tool-sub'; - let add=0, del=0; - tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); - sub.textContent='modifications'; - const cnt=document.createElement('span'); cnt.className='diff-count'; - cnt.innerHTML='+'+add+' -'+del+''; - sub.appendChild(cnt); + sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée) body.appendChild(sub); const pre=document.createElement('pre'); pre.className='diff'; tu.diff.forEach(l=>{ diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 239d7d5..9911af4 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -60,7 +60,7 @@
Mode agent -
+
Mémoire?
diff --git a/internal/jean/ui/src/js/08-chat-render.js b/internal/jean/ui/src/js/08-chat-render.js index 497da1a..0aacd60 100644 --- a/internal/jean/ui/src/js/08-chat-render.js +++ b/internal/jean/ui/src/js/08-chat-render.js @@ -104,6 +104,17 @@ function collapseInstant(el){ requestAnimationFrame(()=>{ bw.style.transition=''; }); } function setLabel(el, text){ el.querySelector('.label').textContent = text; } +// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible +// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture +// survit au repli, donc on le met là plutôt que dans le corps seul. +function setLabelCounts(el, add, del){ + const lab=el.querySelector('.label'); + const cnt=document.createElement('span'); cnt.className='diff-count'; + if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add})); + if(add && del) cnt.appendChild(document.createTextNode(' ')); + if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); + lab.appendChild(cnt); +} // Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN // sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait // avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée @@ -132,6 +143,7 @@ function renderToolMsg(el, tu){ // (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire. const META = { bash: {lbl:'terminal', head:'commande'}, + write: {lbl:'fichier', head:'écriture'}, edit: {lbl:'édition', head:'édition'}, web_search: {lbl:'recherche', head:'recherche web'}, web_open: {lbl:'page web', head:'ouverture'}, @@ -152,6 +164,12 @@ function renderToolMsg(el, tu){ // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; } setLabel(el, lbl); + // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe) + // reporté sur l'étiquette, pour rester lisible bulle repliée. + let add=0, del=0; + if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } + else if(tu.body){ add=tu.body.split('\n').length; } + if(add||del) setLabelCounts(el, add, del); const body=bodyOf(el); body.innerHTML=''; const head=document.createElement('div'); head.className='tool-head'; head.textContent = meta.head; @@ -162,16 +180,37 @@ function renderToolMsg(el, tu){ if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); } pre.appendChild(code); body.appendChild(pre); } + // Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à + // ligne, dans la même forme que le diff final, pour que la bulle se remplisse + // sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la + // dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement + // ferait clignoter le bloc entier. + if(tu.body && !(tu.diff && tu.diff.length)){ + const lines=tu.body.split('\n'); + const sub=document.createElement('div'); sub.className='tool-sub'; + sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée) + body.appendChild(sub); + const pre=document.createElement('pre'); pre.className='diff live'; + lines.forEach((t,i)=>{ + const ln=document.createElement('span'); + ln.className='dl add'+(i===lines.length-1?' fresh':''); + ln.textContent='+ '+t; + if(i===lines.length-1 && tu.typing){ + const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; + ln.appendChild(car); + } + pre.appendChild(ln); + }); + body.appendChild(pre); + // Le bloc est re-créé à chaque événement : on le recale en bas pour suivre + // la ligne en cours (max-height côté CSS l'empêche de pousser le fil). + pre.scrollTop = pre.scrollHeight; + } // Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert, // retirées en rouge, contexte en gris — comme un diff de terminal. if(tu.diff && tu.diff.length){ const sub=document.createElement('div'); sub.className='tool-sub'; - let add=0, del=0; - tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); - sub.textContent='modifications'; - const cnt=document.createElement('span'); cnt.className='diff-count'; - cnt.innerHTML='+'+add+' -'+del+''; - sub.appendChild(cnt); + sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée) body.appendChild(sub); const pre=document.createElement('pre'); pre.className='diff'; tu.diff.forEach(l=>{ diff --git a/internal/jean/ui/src/styles.css b/internal/jean/ui/src/styles.css index 380fbff..b8f5f25 100644 --- a/internal/jean/ui/src/styles.css +++ b/internal/jean/ui/src/styles.css @@ -247,6 +247,13 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)} .msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)} .msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)} +/* Écriture en direct : le bloc se remplit ligne à ligne pendant que le modèle + tape. Seule la dernière ligne est en fondu (.fresh) — animer tout le bloc à + chaque nouvelle ligne le ferait clignoter. Hauteur bornée + scroll auto en + bas (voir 08-chat-render.js) pour qu'un long script ne pousse pas le fil. */ +.msg.tool pre.diff.live .dl.fresh{animation:difffresh .18s ease-out} +@keyframes difffresh{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}} +@media (prefers-reduced-motion:reduce){.msg.tool pre.diff.live .dl.fresh{animation:none}} .diff-count{margin-left:8px;letter-spacing:0;text-transform:none} .diff-count .a{color:var(--add)} .diff-count .d{color:var(--del)}