From 91d179661588775f3075e7f55adf46ec5b20f25f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 13:24:53 +0000 Subject: [PATCH] =?UTF-8?q?R=C3=A9sultats=20d'outils=20:=20aper=C3=A7u=20+?= =?UTF-8?q?=20=C2=AB=20voir=20plus=20=C2=BB,=20vrai=20compteur,=20MCP=20co?= =?UTF-8?q?mplet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le flux n'envoie plus tout le résultat d'un outil : un aperçu de 1600 caractères, sa taille RÉELLE et l'id de l'appel. Le bouton « voir plus » charge le reste à la demande (/api/chat/tool-result), et déplie vraiment le bloc (plafond de 280 px levé) au lieu de le laisser scroller. « voir plus » et « copier » vivent dans la même barre, ancrée en bas à droite d'un bloc-parent non scrollant : elle reste au coin quand on scrolle le résultat. Le compteur « ~N tok » de la bulle disait la taille de ce que l'UI avait reçu, donc toujours le plafond sur un long résultat. Il lit maintenant result_chars, la taille réelle. MCP : plus de troncature à 12000 caractères avant le modèle (même règle que la lecture mémoire). Un serveur MCP est configuré exprès pour ce qu'il rend ; le couper au milieu rendait la réponse inutilisable. L'UI, elle, n'en affiche que l'aperçu. Reprises d'AJEAN 0.15.1, 0.15.2 et 0.15.4. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY --- internal/loki/chat_conversation.go | 10 +++ internal/loki/chat_internet_tools.go | 3 +- internal/loki/llm_client.go | 56 +++++++++++---- internal/loki/mcp_client.go | 10 +-- internal/loki/ui/index.html | 86 +++++++++++++++++++++-- internal/loki/ui/src/js/08-chat-render.js | 72 +++++++++++++++++-- internal/loki/ui/src/styles.css | 14 ++++ internal/loki/web_chat.go | 29 ++++++++ internal/loki/web_server.go | 1 + 9 files changed, 250 insertions(+), 31 deletions(-) diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 5468cbf..33041a4 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -567,6 +567,16 @@ 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, } + // Taille réelle du résultat (l'aperçu n'en montre qu'un début) : le + // compteur ~N tok de la bulle affiche la vraie valeur, pas celle de + // l'aperçu. + if ev.ToolUsed.ResultChars > 0 { + tu["result_chars"] = ev.ToolUsed.ResultChars + } + // Référence pour charger le reste à la demande (bouton « voir plus »). + if ev.ToolUsed.ResultID != "" { + tu["result_id"] = ev.ToolUsed.ResultID + } // 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 != "" { diff --git a/internal/loki/chat_internet_tools.go b/internal/loki/chat_internet_tools.go index 80719cb..d0392dc 100644 --- a/internal/loki/chat_internet_tools.go +++ b/internal/loki/chat_internet_tools.go @@ -97,7 +97,8 @@ func webGrepTool() Tool { // ─── exécution des outils (appelée par le dispatch de llm_client.go) ─────────────── // webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme -// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce +// toolMaxOutput (8000) pour le shell. (La mémoire et MCP, eux, renvoient tout +// au modèle et laissent l'UI charger l'aperçu.) Sans ce // plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000 // caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui // déclenchait des compactages en cascade au milieu du raisonnement. diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 2b1a179..1af2f22 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -449,24 +449,50 @@ type ToolUsedEvent struct { // pensait à recopier la ligne markdown rendue par l'outil : un petit modèle // l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée. Image string + // ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en + // porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle, qui affichait + // sinon la taille de l'aperçu — toujours le même chiffre, quelle que soit la + // page lue. + ResultChars int + // ResultID : identifiant (tool_call_id) avec lequel l'UI CHARGE le résultat + // complet à la demande via /api/chat/tool-result. Le flux ne transporte que + // l'aperçu ; le reste ne descend qu'au clic sur « voir plus ». Vide = Result + // est déjà complet (rien à charger). + ResultID string } -// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI. -// Aligné sur le plus haut plafond côté modèle (mcpMaxOutput = 12000 ; shell et -// web = 8000) : le modèle et l'UI voient donc la même chose, et l'étiquette -// « ~N tok » de la bulle dit la VRAIE taille du résultat. +// toolPreviewChars borne l'aperçu de résultat d'outil envoyé à l'UI. Le résultat +// COMPLET reste côté serveur (conv.Messages) et se charge à la demande. // -// Avant, cette borne était à 4000 : toute page web un peu longue s'affichait -// « ~1004 tok » — la valeur du plafond, pas celle de la page. Le compteur -// mentait, et il mentait toujours avec le même chiffre. -const shownDisplayMax = 12000 +// Avant, l'UI recevait tout le résultat tronqué à 12000 caractères : chaque tour +// poussait des dizaines de kilo-octets dans le flux SSE et dans le journal +// persisté, pour un bloc que personne ne déplie une fois sur dix. +const toolPreviewChars = 1600 -// shownResult prépare un résultat d'outil pour l'affichage. -func shownResult(s string) string { - if r := []rune(s); len(r) > shownDisplayMax { - return string(r[:shownDisplayMax]) + "\n…[tronqué]" +// toolResultPreview renvoie (aperçu, taille réelle en runes, coupé ?). +func toolResultPreview(s string) (string, int, bool) { + r := []rune(s) + if len(r) <= toolPreviewChars { + return s, len(r), false } - return s + return string(r[:toolPreviewChars]), len(r), true +} + +// fillToolResult pose sur l'événement l'aperçu envoyé à l'UI, la taille réelle +// (pour le compteur ~N tok) et, si le résultat est coupé et qu'on a un id, la +// référence pour charger le reste à la demande. Sans id utilisable, on envoie le +// résultat entier : mieux vaut un flux plus lourd qu'un bloc qu'on ne peut plus +// déplier. +func fillToolResult(ev *ToolUsedEvent, result, tcID string) *ToolUsedEvent { + prev, n, cut := toolResultPreview(result) + ev.ResultChars = n + if cut && tcID != "" { + ev.Result = prev + ev.ResultID = tcID + } else { + ev.Result = result + } + return ev } // dedupableTool indique si un appel RIGOUREUSEMENT identique (même outil, mêmes @@ -1269,7 +1295,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if prev, seen := doneCalls[callKey]; seen && dedupableTool(tc.Function.Name) { repeatCount[callKey]++ result = repeatedCallResult(prev, repeatCount[callKey]) - cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}}) + cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result, tc.ID)}) toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} messages = append(messages, toolMsg) extra = append(extra, toolMsg) @@ -1453,7 +1479,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if tc.Function.Name == "web_screenshot" { shot = capturedRelPath(result) } - cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff, Image: shot}}) + cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Image: shot}, result, tc.ID)}) toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} messages = append(messages, toolMsg) extra = append(extra, toolMsg) diff --git a/internal/loki/mcp_client.go b/internal/loki/mcp_client.go index 4d16c9e..f55b54e 100644 --- a/internal/loki/mcp_client.go +++ b/internal/loki/mcp_client.go @@ -48,8 +48,6 @@ const ( mcpConnectTimeoutCold = 3 * time.Minute // mcpCallTimeout borne un appel d'outil MCP. mcpCallTimeout = 120 * time.Second - // mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput). - mcpMaxOutput = 12000 ) // mcpSession est une connexion vivante à un serveur MCP. @@ -398,10 +396,12 @@ func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) { if err != nil { return "", err } + // Le résultat complet part au modèle (même règle que la lecture mémoire) : + // plus de troncature ici. Un serveur MCP est configuré EXPRÈS par le + // propriétaire de la machine pour ce qu'il rend ; le couper au milieu, c'est + // rendre la réponse inutilisable. L'UI, elle, n'affiche qu'un aperçu et + // charge le reste à la demande (voir fillToolResult et /api/chat/tool-result). out := flattenMCPContent(res) - if r := []rune(out); len(r) > mcpMaxOutput { - out = string(r[:mcpMaxOutput]) + "\n…[tronqué]" - } if res.IsError { return "[l'outil a renvoyé une erreur]\n" + out, nil } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index b59d562..a7ca93d 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -475,6 +475,20 @@ button:disabled{opacity:.5;cursor:not-allowed} .diff-count .a{color:var(--add)} .diff-count .d{color:var(--del)} .msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px} +/* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre + ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre + scrolle (max-height), donc la barre est portée par le bloc-parent NON + scrollant (.tool-block) : elle reste au coin quoi qu'on scrolle. */ +.msg.tool .tool-block{position:relative} +.msg.tool .tool-block .tool-actions{position:absolute;bottom:7px;right:7px;display:flex;gap:6px;align-items:center;z-index:1} +.msg.tool .tool-actions .copybtn{position:static;bottom:auto;right:auto} +.msg.tool .tool-more{padding:3px 8px;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;opacity:.5;transition:opacity .15s,color .15s,border-color .15s} +.msg.tool .tool-block:hover .tool-actions .tool-more,.msg.tool .tool-block:hover .tool-actions .copybtn{opacity:1} +.msg.tool .tool-more:hover{border-color:var(--accent);color:var(--accent)} +.msg.tool .tool-more:disabled{cursor:default} +/* Déplié : on lève le plafond de hauteur pour voir VRAIMENT plus, au lieu de + rester enfermé dans une fenêtre de 280 px qu'il faut scroller. */ +.msg.tool pre.expanded{max-height:none} .msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px} .msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre} .msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite} @@ -6407,7 +6421,10 @@ function renderToolMsg(el, tu){ meta = meta || {lbl:'mémoire', head:'mémoire'}; let lbl = meta.lbl; // 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'; } + // Taille RÉELLE (result_chars) quand le flux n'a envoyé qu'un aperçu — sinon + // la bulle affichait toujours le même chiffre, celui du plafond d'aperçu. + const realChars = tu.result_chars || (tu.result ? tu.result.length : 0); + if(realChars){ lbl += ' · ~' + Math.max(1, Math.round(realChars/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. @@ -6486,8 +6503,49 @@ function renderToolMsg(el, tu){ const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse'; body.appendChild(sub); const pre=document.createElement('pre'); - const code=document.createElement('code'); code.textContent=tu.result; - pre.appendChild(code); body.appendChild(pre); + const code=document.createElement('code'); + const preview=tu.result, rid=tu.result_id, PREVIEW=1600; + // Deux cas : + // - result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé), + // « voir plus » CHARGE le reste à la demande via l'API ; + // - sinon : le résultat est déjà complet ; s'il est long, « voir plus » le + // déplie localement (rien à charger). + if(rid){ + code.textContent=preview+'…'; + pre.appendChild(code); + const more=document.createElement('button'); + more.className='tool-more'; more.type='button'; more.textContent='voir plus'; + let full=null, open=false; + more.onclick=async(e)=>{ e.stopPropagation(); + if(full===null){ + more.disabled=true; more.textContent='…'; + // Résultat introuvable côté serveur (compacté, autre discussion) : on + // garde l'aperçu plutôt que de vider la bulle. + try{ const j=await jget('/api/chat/tool-result?id='+encodeURIComponent(rid)); full=(j&&j.result)||preview; } + catch(_){ full=preview; } + more.disabled=false; + } + open=!open; + code.textContent = open ? full : preview+'…'; + pre.classList.toggle('expanded', open); + more.textContent = open ? 'voir moins' : 'voir plus'; + }; + body.appendChild(toolBlock(pre, more)); + } else if(preview.length>PREVIEW){ + code.textContent=preview.slice(0,PREVIEW)+'…'; + pre.appendChild(code); + const more=document.createElement('button'); + more.className='tool-more'; more.type='button'; more.textContent='voir plus'; + let open=false; + more.onclick=(e)=>{ e.stopPropagation(); open=!open; + code.textContent = open ? preview : preview.slice(0,PREVIEW)+'…'; + pre.classList.toggle('expanded', open); + more.textContent = open ? 'voir moins' : 'voir plus'; + }; + body.appendChild(toolBlock(pre, more)); + } else { + code.textContent=preview; pre.appendChild(code); body.appendChild(pre); + } } else if(!tu.done && !tu.typing){ const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…'; body.appendChild(wait); @@ -6499,10 +6557,26 @@ function renderToolMsg(el, tu){ const bw = el.querySelector('.bodywrap'); if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; } } +// Bloc d'un résultat d'outil : le pre scrolle (max-height), donc la barre +// d'actions vit sur un bloc-parent NON scrollant, ancrée en bas à droite — elle +// reste au coin quoi qu'on scrolle dans le résultat. La barre réunit « voir +// plus » (passé ici) et, ajouté ensuite par addCopyButtons, « copier ». +function toolBlock(pre, more){ + const block=document.createElement('div'); block.className='tool-block'; + block.appendChild(pre); + const bar=document.createElement('div'); bar.className='tool-actions'; + bar.appendChild(more); + block.appendChild(bar); + return block; +} // Inject a "copier" button into every
 code block (idempotent).
 function addCopyButtons(root){
   root.querySelectorAll('pre').forEach(pre=>{
-    if(pre.querySelector('.copybtn')) return;
+    // Résultat d'outil : le pre est enrobé dans .tool-block et la barre
+    // d'actions est SŒUR du pre (pas dedans) ; on cherche donc le copier dans
+    // ce périmètre.
+    const scope = pre.closest('.tool-block') || pre;
+    if(scope.querySelector('.copybtn')) return;
     // Pas de bouton copier sur un diff : on copierait les préfixes + / - .
     if(pre.classList.contains('diff')) return;
     const btn=document.createElement('button');
@@ -6515,7 +6589,9 @@ function addCopyButtons(root){
       btn.textContent='copié ✓'; btn.classList.add('done');
       setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
     };
-    pre.appendChild(btn);
+    // S'il y a une barre d'actions (résultat d'outil avec « voir plus »), le
+    // bouton copier s'y range à côté ; sinon il se colle en bas à droite du bloc.
+    (scope.querySelector('.tool-actions')||pre).appendChild(btn);
   });
 }
 // Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js
index 84d6556..51cb282 100644
--- a/internal/loki/ui/src/js/08-chat-render.js
+++ b/internal/loki/ui/src/js/08-chat-render.js
@@ -286,7 +286,10 @@ function renderToolMsg(el, tu){
   meta = meta || {lbl:'mémoire', head:'mémoire'};
   let lbl = meta.lbl;
   // 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'; }
+  // Taille RÉELLE (result_chars) quand le flux n'a envoyé qu'un aperçu — sinon
+  // la bulle affichait toujours le même chiffre, celui du plafond d'aperçu.
+  const realChars = tu.result_chars || (tu.result ? tu.result.length : 0);
+  if(realChars){ lbl += '  ·  ~' + Math.max(1, Math.round(realChars/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.
@@ -365,8 +368,49 @@ function renderToolMsg(el, tu){
     const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
     body.appendChild(sub);
     const pre=document.createElement('pre');
-    const code=document.createElement('code'); code.textContent=tu.result;
-    pre.appendChild(code); body.appendChild(pre);
+    const code=document.createElement('code');
+    const preview=tu.result, rid=tu.result_id, PREVIEW=1600;
+    // Deux cas :
+    //  - result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
+    //    « voir plus » CHARGE le reste à la demande via l'API ;
+    //  - sinon : le résultat est déjà complet ; s'il est long, « voir plus » le
+    //    déplie localement (rien à charger).
+    if(rid){
+      code.textContent=preview+'…';
+      pre.appendChild(code);
+      const more=document.createElement('button');
+      more.className='tool-more'; more.type='button'; more.textContent='voir plus';
+      let full=null, open=false;
+      more.onclick=async(e)=>{ e.stopPropagation();
+        if(full===null){
+          more.disabled=true; more.textContent='…';
+          // Résultat introuvable côté serveur (compacté, autre discussion) : on
+          // garde l'aperçu plutôt que de vider la bulle.
+          try{ const j=await jget('/api/chat/tool-result?id='+encodeURIComponent(rid)); full=(j&&j.result)||preview; }
+          catch(_){ full=preview; }
+          more.disabled=false;
+        }
+        open=!open;
+        code.textContent = open ? full : preview+'…';
+        pre.classList.toggle('expanded', open);
+        more.textContent = open ? 'voir moins' : 'voir plus';
+      };
+      body.appendChild(toolBlock(pre, more));
+    } else if(preview.length>PREVIEW){
+      code.textContent=preview.slice(0,PREVIEW)+'…';
+      pre.appendChild(code);
+      const more=document.createElement('button');
+      more.className='tool-more'; more.type='button'; more.textContent='voir plus';
+      let open=false;
+      more.onclick=(e)=>{ e.stopPropagation(); open=!open;
+        code.textContent = open ? preview : preview.slice(0,PREVIEW)+'…';
+        pre.classList.toggle('expanded', open);
+        more.textContent = open ? 'voir moins' : 'voir plus';
+      };
+      body.appendChild(toolBlock(pre, more));
+    } else {
+      code.textContent=preview; pre.appendChild(code); body.appendChild(pre);
+    }
   } else if(!tu.done && !tu.typing){
     const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…';
     body.appendChild(wait);
@@ -378,10 +422,26 @@ function renderToolMsg(el, tu){
   const bw = el.querySelector('.bodywrap');
   if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
 }
+// Bloc d'un résultat d'outil : le pre scrolle (max-height), donc la barre
+// d'actions vit sur un bloc-parent NON scrollant, ancrée en bas à droite — elle
+// reste au coin quoi qu'on scrolle dans le résultat. La barre réunit « voir
+// plus » (passé ici) et, ajouté ensuite par addCopyButtons, « copier ».
+function toolBlock(pre, more){
+  const block=document.createElement('div'); block.className='tool-block';
+  block.appendChild(pre);
+  const bar=document.createElement('div'); bar.className='tool-actions';
+  bar.appendChild(more);
+  block.appendChild(bar);
+  return block;
+}
 // Inject a "copier" button into every 
 code block (idempotent).
 function addCopyButtons(root){
   root.querySelectorAll('pre').forEach(pre=>{
-    if(pre.querySelector('.copybtn')) return;
+    // Résultat d'outil : le pre est enrobé dans .tool-block et la barre
+    // d'actions est SŒUR du pre (pas dedans) ; on cherche donc le copier dans
+    // ce périmètre.
+    const scope = pre.closest('.tool-block') || pre;
+    if(scope.querySelector('.copybtn')) return;
     // Pas de bouton copier sur un diff : on copierait les préfixes + / - .
     if(pre.classList.contains('diff')) return;
     const btn=document.createElement('button');
@@ -394,7 +454,9 @@ function addCopyButtons(root){
       btn.textContent='copié ✓'; btn.classList.add('done');
       setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
     };
-    pre.appendChild(btn);
+    // S'il y a une barre d'actions (résultat d'outil avec « voir plus »), le
+    // bouton copier s'y range à côté ; sinon il se colle en bas à droite du bloc.
+    (scope.querySelector('.tool-actions')||pre).appendChild(btn);
   });
 }
 // Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index e44f70f..7e88602 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -444,6 +444,20 @@ button:disabled{opacity:.5;cursor:not-allowed}
 .diff-count .a{color:var(--add)}
 .diff-count .d{color:var(--del)}
 .msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
+/* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre
+   ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre
+   scrolle (max-height), donc la barre est portée par le bloc-parent NON
+   scrollant (.tool-block) : elle reste au coin quoi qu'on scrolle. */
+.msg.tool .tool-block{position:relative}
+.msg.tool .tool-block .tool-actions{position:absolute;bottom:7px;right:7px;display:flex;gap:6px;align-items:center;z-index:1}
+.msg.tool .tool-actions .copybtn{position:static;bottom:auto;right:auto}
+.msg.tool .tool-more{padding:3px 8px;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;opacity:.5;transition:opacity .15s,color .15s,border-color .15s}
+.msg.tool .tool-block:hover .tool-actions .tool-more,.msg.tool .tool-block:hover .tool-actions .copybtn{opacity:1}
+.msg.tool .tool-more:hover{border-color:var(--accent);color:var(--accent)}
+.msg.tool .tool-more:disabled{cursor:default}
+/* Déplié : on lève le plafond de hauteur pour voir VRAIMENT plus, au lieu de
+   rester enfermé dans une fenêtre de 280 px qu'il faut scroller. */
+.msg.tool pre.expanded{max-height:none}
 .msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
 .msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
 .msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go
index 5db8bda..12e7b11 100644
--- a/internal/loki/web_chat.go
+++ b/internal/loki/web_chat.go
@@ -202,6 +202,35 @@ func handleChatState(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, 200, conv.state())
 }
 
+// handleToolResult renvoie le résultat COMPLET d'un appel d'outil, chargé à la
+// demande par le bouton « voir plus » de la bulle (le flux ne transporte qu'un
+// aperçu). Le résultat complet vit dans la vue « modèle » (conv.Messages), repéré
+// par son tool_call_id. Absent (compacté, archivé, discussion changée) → 404, et
+// l'UI garde son aperçu plutôt que d'afficher une erreur.
+func handleToolResult(w http.ResponseWriter, r *http.Request) {
+	id := strings.TrimSpace(r.URL.Query().Get("id"))
+	if id == "" {
+		sendJSON(w, 400, map[string]any{"error": "id manquant"})
+		return
+	}
+	conv.mu.Lock()
+	found, ok := "", false
+	for _, m := range conv.Messages {
+		if m.Role == "tool" && m.ToolCallID == id {
+			if s, isStr := m.Content.(string); isStr {
+				found, ok = s, true
+			}
+			break
+		}
+	}
+	conv.mu.Unlock()
+	if !ok {
+		sendJSON(w, 404, map[string]any{"error": "résultat non disponible"})
+		return
+	}
+	sendJSON(w, 200, map[string]any{"result": found})
+}
+
 func handleChat(w http.ResponseWriter, r *http.Request) {
 	var body chatReq
 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go
index 2e65237..7b69e98 100644
--- a/internal/loki/web_server.go
+++ b/internal/loki/web_server.go
@@ -308,6 +308,7 @@ func newWebMux() *http.ServeMux {
 	api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
 	api("/api/chat/compact", handleChatCompact)   // compaction manuelle du contexte
 	api("/api/chat/state", handleChatState)       // instantané léger {seq, generating, ctx_used}
+	api("/api/chat/tool-result", handleToolResult) // résultat complet d'un outil, chargé à la demande (bouton « voir plus »)
 	api("/api/chat/export", handleChatExport)     // téléchargement du fil (?format=md|json)
 	api("/api/e2e/chat", handleE2EChat)           // même flux mais chiffré E2E (boîte noire via le relais)
 	return mux