detached 5905729

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 {
111111 hash uint64
112112 width int
113113 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
114121 }
115122
116123 // InvalidateRenderCache drops all cached message renders. The tui
@@ -189,7 +196,28 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
189196 rendered := make([][]string, len(v.Messages))
190197 total := 0
191198 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)
193221 rendered[idx] = lines
194222 total += len(lines) + 1 // +1 for the blank separator row
195223 }
@@ -208,7 +236,25 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
208236 // overlay below is the real content and a naked "zot" bar
209237 // above it reads as a stray empty message.
210238 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+ }
212258 // Stream the partial assistant text through the same markdown
213259 // renderer used for finalised messages so code fences, diffs,
214260 // lists, and inline styles look the same while streaming and
@@ -287,18 +333,19 @@ func (v *View) refreshToolPaths() {
287333 // been rendered before. The slice returned is shared — callers must
288334 // not mutate it; Build() only ever appends to its own `out` so the
289335 // 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 {
291337 key := msgCacheKey{
292338 hash: hashMessage(m),
293339 width: width,
294340 expandAll: v.ExpandAll,
341+ turnOpen: turnOpen,
295342 }
296343 if v.renderCache != nil {
297344 if lines, ok := v.renderCache[key]; ok {
298345 return lines
299346 }
300347 }
301- lines := v.renderMessage(m, width)
348+ lines := v.renderMessage(m, width, turnOpen)
302349 if v.renderCache != nil {
303350 // Bound the cache: 4x the current message count is enough to
304351 // survive /compact churn without leaking memory across a very
@@ -408,7 +455,7 @@ func fnv64aWrite(h uint64, p []byte) uint64 {
408455 return h
409456 }
410457
411-func (v *View) renderMessage(m provider.Message, width int) []string {
458+func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string {
412459 var lines []string
413460
414461 // 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 {
438485 }
439486 }
440487 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+ }
443494 // Indent assistant body the same 4 cells the user body uses,
444495 // so the conversation column lines up vertically. The width
445496 // passed into the markdown renderer / wrap is reduced by the
@@ -111,6 +111,13 @@ type msgCacheKey struct {
111 hash uint64111 hash uint64
112 width int112 width int
113 expandAll bool113 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 tui123 // 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 := 0197 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] = lines221 rendered[idx] = lines
194 total += len(lines) + 1 // +1 for the blank separator row222 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" bar236 // 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 markdown258 // 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 and260 // 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 must333 // been rendered before. The slice returned is shared — callers must
288 // not mutate it; Build() only ever appends to its own `out` so the334 // 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 lines345 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 to350 // Bound the cache: 4x the current message count is enough to
304 // survive /compact churn without leaking memory across a very351 // survive /compact churn without leaking memory across a very
@@ -408,7 +455,7 @@ func fnv64aWrite(h uint64, p []byte) uint64 {
408 return h455 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 []string459 var lines []string
413 460
414 // Compaction summary: render as a single muted line at the end461 // 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 width495 // so the conversation column lines up vertically. The width
445 // passed into the markdown renderer / wrap is reduced by the496 // passed into the markdown renderer / wrap is reduced by the

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close