@@ -3,6 +3,9 @@ package tui
33import (
44 "encoding/json"
55 "fmt"
6+ "regexp"
7+ "sort"
8+ "strconv"
69 "strings"
710 "time"
811 "unicode"
@@ -225,6 +228,93 @@ func eventTail(ev client.Event) string {
225228 return s
226229}
227230
231+ // normalizeToolResult prepares raw tool output for DISPLAY ONLY — the stored
232+ // and forwarded data is never touched. It handles two render-noise shapes:
233+ // 1. JSON envelopes (read_file, search_files, …): Go's encoding/json
234+ // HTML-escapes <, >, & in string values, so the odek prompt-injection
235+ // wrapper shows up as <untrusted_content_… — the envelope is
236+ // decoded and the real content rendered, with remaining scalar metadata
237+ // as a one-line footer.
238+ // 2. untrusted_content wrappers, folded into a "⚠ untrusted: <source>"
239+ // badge line. Both the literal tag form (live stream events) and the
240+ // < escaped form (undecoded JSON envelopes) are recognized.
241+ //
242+ // Fail-safe: anything that does not match a known shape exactly is returned
243+ // unchanged — normalization is never lossy.
244+ func normalizeToolResult (data string ) string {
245+ return foldUntrustedWrappers (decodeToolEnvelope (data ))
246+ }
247+
248+ // decodeToolEnvelope unwraps a JSON object with a string "content" field,
249+ // returning the decoded content plus a footer line of the remaining scalar
250+ // metadata (e.g. "total_lines: 430"). Nested objects/arrays, missing
251+ // content, or invalid JSON mean an unknown shape — returned unchanged.
252+ func decodeToolEnvelope (data string ) string {
253+ t := strings .TrimSpace (data )
254+ if len (t ) < 2 || t [0 ] != '{' {
255+ return data
256+ }
257+ var env map [string ]any
258+ if err := json .Unmarshal ([]byte (t ), & env ); err != nil {
259+ return data
260+ }
261+ content , ok := env ["content" ].(string )
262+ if ! ok {
263+ return data
264+ }
265+ meta := make (map [string ]string , len (env )- 1 )
266+ for k , v := range env {
267+ if k == "content" || v == nil {
268+ continue
269+ }
270+ switch x := v .(type ) {
271+ case string :
272+ meta [k ] = x
273+ case float64 :
274+ meta [k ] = strconv .FormatFloat (x , 'f' , - 1 , 64 )
275+ case bool :
276+ meta [k ] = strconv .FormatBool (x )
277+ default :
278+ return data // nested value: not a known envelope shape
279+ }
280+ }
281+ if len (meta ) == 0 {
282+ return content
283+ }
284+ keys := make ([]string , 0 , len (meta ))
285+ for k := range meta {
286+ keys = append (keys , k )
287+ }
288+ sort .Strings (keys )
289+ parts := make ([]string , 0 , len (keys ))
290+ for _ , k := range keys {
291+ parts = append (parts , k + ": " + meta [k ])
292+ }
293+ return content + "\n (" + strings .Join (parts , " · " ) + ")"
294+ }
295+
296+ // untrustedWrapperRe matches an odek prompt-injection wrapper in either its
297+ // literal form or the < escaped form produced by encoding/json.
298+ // Capture groups: 1 = source attribute, 2 = wrapped body.
299+ var untrustedWrapperRe = regexp .MustCompile (
300+ `(?s)(?:<|\\u003c)untrusted_content_[0-9a-f]+ source=\\?"([^"]*)\\?"(?:>|\\u003e)\n?(.*?)\n?(?:<|\\u003c)/untrusted_content_[0-9a-f]+(?:>|\\u003e)` )
301+
302+ // foldUntrustedWrappers replaces each untrusted_content wrapper with a badge
303+ // line naming the source, followed by the wrapped body.
304+ func foldUntrustedWrappers (s string ) string {
305+ if ! strings .Contains (s , "untrusted_content_" ) {
306+ return s
307+ }
308+ return untrustedWrapperRe .ReplaceAllStringFunc (s , func (m string ) string {
309+ sub := untrustedWrapperRe .FindStringSubmatch (m )
310+ badge := "⚠ untrusted: " + sub [1 ]
311+ if body := sub [2 ]; body != "" {
312+ return badge + "\n " + body
313+ }
314+ return badge
315+ })
316+ }
317+
228318// cur returns the index of the active streaming assistant message, or -1.
229319func (m * Model ) cur () int {
230320 if m .curIdx >= 0 && m .curIdx < len (m .msgs ) {
@@ -331,7 +421,7 @@ func argPreview(data string) string {
331421// lines, so the transcript can show a useful excerpt (rendered by renderSteps)
332422// without retaining the unbounded output of a chatty tool.
333423func resultPreview (data string ) string {
334- s := sanitize (data )
424+ s := sanitize (normalizeToolResult ( data ) )
335425 lines := strings .Split (s , "\n " )
336426 const cap = 200
337427 if len (lines ) > cap {
0 commit comments