tui: render whole agent turn under one zot header
Each assistant API message used to draw its own "zot" header, so a turn that round-tripped through several tool_use / tool_result pairs showed multiple headers (or, when the first assistant message was pure tool_use, the header appeared late and earlier tool blocks visibly snapped underneath it once the model started emitting text). Track whether the previous non-compaction message in the transcript belongs to the same turn (assistant or tool role) and suppress the header on subsequent assistant messages and on the streaming overlay while a turn is already open. Header now marks the speaker boundary (you <-> agent), not the underlying API message boundary.
- commit
- 59057297765ee9e57863b845edf29e20239ae8c9
- author
- patriceckhart · · 2026-04-26 17:17 UTC
- parent
- 9cc50ec
- signature
- unsigned
- refs
- tag: v0.1.23
1 file · +58 -7
M internal/tui/view.go +58 -7
| @@ -111,6 +111,13 @@ type msgCacheKey struct { | |||
| 111 | 111 | hash uint64 | |
| 112 | 112 | width int | |
| 113 | 113 | expandAll bool | |
| 114 | + | // turnOpen is true when the previous rendered message belongs to | |
| 115 | + | // the same agent turn (assistant tool_use, or tool result). The | |
| 116 | + | // header ("▍ zot") is suppressed in that case so a single turn | |
| 117 | + | // — even one that spans many assistant/tool message round-trips | |
| 118 | + | // in the underlying API — renders under one header instead of a | |
| 119 | + | // new one per assistant message. | |
| 120 | + | turnOpen bool | |
| 114 | 121 | } | |
| 115 | 122 | ||
| 116 | 123 | // InvalidateRenderCache drops all cached message renders. The tui | |
| @@ -189,7 +196,28 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 189 | 196 | rendered := make([][]string, len(v.Messages)) | |
| 190 | 197 | total := 0 | |
| 191 | 198 | for idx, m := range v.Messages { | |
| 192 | - | lines := v.renderMessageCached(m, width) | |
| 199 | + | // A turn is "open" once the assistant has started responding | |
| 200 | + | // to the most recent user prompt. Walk back over consecutive | |
| 201 | + | // assistant/tool messages: if any non-user message precedes | |
| 202 | + | // this one without a user message in between, we're inside | |
| 203 | + | // the same turn and should not draw a new "▍ zot" header. | |
| 204 | + | turnOpen := false | |
| 205 | + | if m.Role == provider.RoleAssistant { | |
| 206 | + | for j := idx - 1; j >= 0; j-- { | |
| 207 | + | prev := v.Messages[j] | |
| 208 | + | // Skip compaction summary messages — they're | |
| 209 | + | // rendered as a muted footer line (or hidden | |
| 210 | + | // entirely when collapsed) and don't open a turn. | |
| 211 | + | if prev.Meta["compaction"] == "true" { | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | if prev.Role == provider.RoleAssistant || prev.Role == provider.RoleTool { | |
| 215 | + | turnOpen = true | |
| 216 | + | } | |
| 217 | + | break | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | lines := v.renderMessageCached(m, width, turnOpen) | |
| 193 | 221 | rendered[idx] = lines | |
| 194 | 222 | total += len(lines) + 1 // +1 for the blank separator row | |
| 195 | 223 | } | |
| @@ -208,7 +236,25 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 208 | 236 | // overlay below is the real content and a naked "zot" bar | |
| 209 | 237 | // above it reads as a stray empty message. | |
| 210 | 238 | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | |
| 211 | - | out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) | |
| 239 | + | // Suppress the streaming "▍ zot" header if the turn is | |
| 240 | + | // already open (a previous assistant/tool message in this | |
| 241 | + | // turn already rendered one). Otherwise the streaming text | |
| 242 | + | // would jump from headerless to under a new header once it | |
| 243 | + | // finalises into a transcript message. | |
| 244 | + | turnOpen := false | |
| 245 | + | for j := len(v.Messages) - 1; j >= 0; j-- { | |
| 246 | + | prev := v.Messages[j] | |
| 247 | + | if prev.Meta["compaction"] == "true" { | |
| 248 | + | continue | |
| 249 | + | } | |
| 250 | + | if prev.Role == provider.RoleAssistant || prev.Role == provider.RoleTool { | |
| 251 | + | turnOpen = true | |
| 252 | + | } | |
| 253 | + | break | |
| 254 | + | } | |
| 255 | + | if !turnOpen { | |
| 256 | + | out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) | |
| 257 | + | } | |
| 212 | 258 | // Stream the partial assistant text through the same markdown | |
| 213 | 259 | // renderer used for finalised messages so code fences, diffs, | |
| 214 | 260 | // lists, and inline styles look the same while streaming and | |
| @@ -287,18 +333,19 @@ func (v *View) refreshToolPaths() { | |||
| 287 | 333 | // been rendered before. The slice returned is shared — callers must | |
| 288 | 334 | // not mutate it; Build() only ever appends to its own `out` so the | |
| 289 | 335 | // shared slice is safe. | |
| 290 | - | func (v *View) renderMessageCached(m provider.Message, width int) []string { | |
| 336 | + | func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) []string { | |
| 291 | 337 | key := msgCacheKey{ | |
| 292 | 338 | hash: hashMessage(m), | |
| 293 | 339 | width: width, | |
| 294 | 340 | expandAll: v.ExpandAll, | |
| 341 | + | turnOpen: turnOpen, | |
| 295 | 342 | } | |
| 296 | 343 | if v.renderCache != nil { | |
| 297 | 344 | if lines, ok := v.renderCache[key]; ok { | |
| 298 | 345 | return lines | |
| 299 | 346 | } | |
| 300 | 347 | } | |
| 301 | - | lines := v.renderMessage(m, width) | |
| 348 | + | lines := v.renderMessage(m, width, turnOpen) | |
| 302 | 349 | if v.renderCache != nil { | |
| 303 | 350 | // Bound the cache: 4x the current message count is enough to | |
| 304 | 351 | // survive /compact churn without leaking memory across a very | |
| @@ -408,7 +455,7 @@ func fnv64aWrite(h uint64, p []byte) uint64 { | |||
| 408 | 455 | return h | |
| 409 | 456 | } | |
| 410 | 457 | ||
| 411 | - | func (v *View) renderMessage(m provider.Message, width int) []string { | |
| 458 | + | func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string { | |
| 412 | 459 | var lines []string | |
| 413 | 460 | ||
| 414 | 461 | // Compaction summary: render as a single muted line at the end | |
| @@ -438,8 +485,12 @@ func (v *View) renderMessage(m provider.Message, width int) []string { | |||
| 438 | 485 | } | |
| 439 | 486 | } | |
| 440 | 487 | case provider.RoleAssistant: | |
| 441 | - | header := v.Theme.FG256(v.Theme.Assistant, "▍ zot") | |
| 442 | - | lines = append(lines, header) | |
| 488 | + | // Only draw the agent header at the start of a turn. Mid-turn | |
| 489 | + | // assistant messages (e.g. another tool_use round-trip after a | |
| 490 | + | // tool result) reuse the header that's already on screen. | |
| 491 | + | if !turnOpen { | |
| 492 | + | lines = append(lines, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) | |
| 493 | + | } | |
| 443 | 494 | // Indent assistant body the same 4 cells the user body uses, | |
| 444 | 495 | // so the conversation column lines up vertically. The width | |
| 445 | 496 | // passed into the markdown renderer / wrap is reduced by the | |
| @@ -111,6 +111,13 @@ type msgCacheKey struct { | |||||
| 111 | hash uint64 | 111 | hash uint64 | ||
| 112 | width int | 112 | width int | ||
| 113 | expandAll bool | 113 | expandAll bool | ||
| 114 | + | // turnOpen is true when the previous rendered message belongs to | |||
| 115 | + | // the same agent turn (assistant tool_use, or tool result). The | |||
| 116 | + | // header ("▍ zot") is suppressed in that case so a single turn | |||
| 117 | + | // — even one that spans many assistant/tool message round-trips | |||
| 118 | + | // in the underlying API — renders under one header instead of a | |||
| 119 | + | // new one per assistant message. | |||
| 120 | + | turnOpen bool | |||
| 114 | } | 121 | } | ||
| 115 | 122 | ||||
| 116 | // InvalidateRenderCache drops all cached message renders. The tui | 123 | // InvalidateRenderCache drops all cached message renders. The tui | ||
| @@ -189,7 +196,28 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 189 | rendered := make([][]string, len(v.Messages)) | 196 | rendered := make([][]string, len(v.Messages)) | ||
| 190 | total := 0 | 197 | total := 0 | ||
| 191 | for idx, m := range v.Messages { | 198 | for idx, m := range v.Messages { | ||
| 192 | - | lines := v.renderMessageCached(m, width) | 199 | + | // A turn is "open" once the assistant has started responding |
| 200 | + | // to the most recent user prompt. Walk back over consecutive | |||
| 201 | + | // assistant/tool messages: if any non-user message precedes | |||
| 202 | + | // this one without a user message in between, we're inside | |||
| 203 | + | // the same turn and should not draw a new "▍ zot" header. | |||
| 204 | + | turnOpen := false | |||
| 205 | + | if m.Role == provider.RoleAssistant { | |||
| 206 | + | for j := idx - 1; j >= 0; j-- { | |||
| 207 | + | prev := v.Messages[j] | |||
| 208 | + | // Skip compaction summary messages — they're | |||
| 209 | + | // rendered as a muted footer line (or hidden | |||
| 210 | + | // entirely when collapsed) and don't open a turn. | |||
| 211 | + | if prev.Meta["compaction"] == "true" { | |||
| 212 | + | continue | |||
| 213 | + | } | |||
| 214 | + | if prev.Role == provider.RoleAssistant || prev.Role == provider.RoleTool { | |||
| 215 | + | turnOpen = true | |||
| 216 | + | } | |||
| 217 | + | break | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | lines := v.renderMessageCached(m, width, turnOpen) | |||
| 193 | rendered[idx] = lines | 221 | rendered[idx] = lines | ||
| 194 | total += len(lines) + 1 // +1 for the blank separator row | 222 | total += len(lines) + 1 // +1 for the blank separator row | ||
| 195 | } | 223 | } | ||
| @@ -208,7 +236,25 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 208 | // overlay below is the real content and a naked "zot" bar | 236 | // overlay below is the real content and a naked "zot" bar | ||
| 209 | // above it reads as a stray empty message. | 237 | // above it reads as a stray empty message. | ||
| 210 | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | 238 | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | ||
| 211 | - | out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) | 239 | + | // Suppress the streaming "▍ zot" header if the turn is |
| 240 | + | // already open (a previous assistant/tool message in this | |||
| 241 | + | // turn already rendered one). Otherwise the streaming text | |||
| 242 | + | // would jump from headerless to under a new header once it | |||
| 243 | + | // finalises into a transcript message. | |||
| 244 | + | turnOpen := false | |||
| 245 | + | for j := len(v.Messages) - 1; j >= 0; j-- { | |||
| 246 | + | prev := v.Messages[j] | |||
| 247 | + | if prev.Meta["compaction"] == "true" { | |||
| 248 | + | continue | |||
| 249 | + | } | |||
| 250 | + | if prev.Role == provider.RoleAssistant || prev.Role == provider.RoleTool { | |||
| 251 | + | turnOpen = true | |||
| 252 | + | } | |||
| 253 | + | break | |||
| 254 | + | } | |||
| 255 | + | if !turnOpen { | |||
| 256 | + | out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) | |||
| 257 | + | } | |||
| 212 | // Stream the partial assistant text through the same markdown | 258 | // Stream the partial assistant text through the same markdown | ||
| 213 | // renderer used for finalised messages so code fences, diffs, | 259 | // renderer used for finalised messages so code fences, diffs, | ||
| 214 | // lists, and inline styles look the same while streaming and | 260 | // lists, and inline styles look the same while streaming and | ||
| @@ -287,18 +333,19 @@ func (v *View) refreshToolPaths() { | |||||
| 287 | // been rendered before. The slice returned is shared — callers must | 333 | // been rendered before. The slice returned is shared — callers must | ||
| 288 | // not mutate it; Build() only ever appends to its own `out` so the | 334 | // not mutate it; Build() only ever appends to its own `out` so the | ||
| 289 | // shared slice is safe. | 335 | // shared slice is safe. | ||
| 290 | - | func (v *View) renderMessageCached(m provider.Message, width int) []string { | 336 | + | func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) []string { |
| 291 | key := msgCacheKey{ | 337 | key := msgCacheKey{ | ||
| 292 | hash: hashMessage(m), | 338 | hash: hashMessage(m), | ||
| 293 | width: width, | 339 | width: width, | ||
| 294 | expandAll: v.ExpandAll, | 340 | expandAll: v.ExpandAll, | ||
| 341 | + | turnOpen: turnOpen, | |||
| 295 | } | 342 | } | ||
| 296 | if v.renderCache != nil { | 343 | if v.renderCache != nil { | ||
| 297 | if lines, ok := v.renderCache[key]; ok { | 344 | if lines, ok := v.renderCache[key]; ok { | ||
| 298 | return lines | 345 | return lines | ||
| 299 | } | 346 | } | ||
| 300 | } | 347 | } | ||
| 301 | - | lines := v.renderMessage(m, width) | 348 | + | lines := v.renderMessage(m, width, turnOpen) |
| 302 | if v.renderCache != nil { | 349 | if v.renderCache != nil { | ||
| 303 | // Bound the cache: 4x the current message count is enough to | 350 | // Bound the cache: 4x the current message count is enough to | ||
| 304 | // survive /compact churn without leaking memory across a very | 351 | // survive /compact churn without leaking memory across a very | ||
| @@ -408,7 +455,7 @@ func fnv64aWrite(h uint64, p []byte) uint64 { | |||||
| 408 | return h | 455 | return h | ||
| 409 | } | 456 | } | ||
| 410 | 457 | ||||
| 411 | - | func (v *View) renderMessage(m provider.Message, width int) []string { | 458 | + | func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string { |
| 412 | var lines []string | 459 | var lines []string | ||
| 413 | 460 | ||||
| 414 | // Compaction summary: render as a single muted line at the end | 461 | // Compaction summary: render as a single muted line at the end | ||
| @@ -438,8 +485,12 @@ func (v *View) renderMessage(m provider.Message, width int) []string { | |||||
| 438 | } | 485 | } | ||
| 439 | } | 486 | } | ||
| 440 | case provider.RoleAssistant: | 487 | case provider.RoleAssistant: | ||
| 441 | - | header := v.Theme.FG256(v.Theme.Assistant, "▍ zot") | 488 | + | // Only draw the agent header at the start of a turn. Mid-turn |
| 442 | - | lines = append(lines, header) | 489 | + | // assistant messages (e.g. another tool_use round-trip after a |
| 490 | + | // tool result) reuse the header that's already on screen. | |||
| 491 | + | if !turnOpen { | |||
| 492 | + | lines = append(lines, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) | |||
| 493 | + | } | |||
| 443 | // Indent assistant body the same 4 cells the user body uses, | 494 | // Indent assistant body the same 4 cells the user body uses, | ||
| 444 | // so the conversation column lines up vertically. The width | 495 | // so the conversation column lines up vertically. The width | ||
| 445 | // passed into the markdown renderer / wrap is reduced by the | 496 | // passed into the markdown renderer / wrap is reduced by the | ||