tui: render the final 'turn finished' frame without needing a nudge
two bugs in the redraw throttle were eating the post-turn frame: 1. requestRedraw, when it could redraw immediately (since >= redrawMinInterval), didn't reset pendingRedraw or stop the pendingTimer. so subsequent invalidates within redrawMinInterval saw pendingRedraw == true and were dropped, leaving the ui stale until a key event or scroll triggered a fresh path. 2. the tick branch only called drainPending while busy or with an open dialog. once a turn finished and i.busy went false the tick stopped clearing the pending flag, so any redraw that ended up scheduled (because the dirty channel was saturated under streaming load) had no second chance to fire. fix: - requestRedraw now stops the timer and clears pendingRedraw whenever it does an immediate redraw. throttle state stays consistent. - tick branch always drains pending, busy or not. requestRedraw on top is still gated on busy/dialog so the spinner keeps animating without forcing a redraw on every tick when idle. reproduction: long turn finishes -> spinner stops, but the final assistant text only appears when the user scrolls or types a key. fixed by both changes; either one alone leaves a small race window.
- commit
- 75ed9d87c7203fe98abd6e9b8e46feedeeb943a6
- author
- patriceckhart · · 2026-04-19 09:31 UTC
- parent
- d653cc1
- signature
- unsigned
- refs
- tag: v0.0.9
1 file · +22 -3
M internal/agent/modes/interactive.go +22 -3
| @@ -246,6 +246,16 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 246 | 246 | requestRedraw := func() { | |
| 247 | 247 | since := time.Since(lastRedraw) | |
| 248 | 248 | if since >= redrawMinInterval { | |
| 249 | + | // Redrawing right now subsumes any pending redraw, so clear | |
| 250 | + | // the throttle state. Without this, a pending flag stays | |
| 251 | + | // stuck at true and subsequent invalidate() calls within | |
| 252 | + | // redrawMinInterval get dropped — which is exactly how the | |
| 253 | + | // final "turn finished" frame went missing until the user | |
| 254 | + | // nudged the ui by typing or scrolling. | |
| 255 | + | if pendingTimer != nil { | |
| 256 | + | pendingTimer.Stop() | |
| 257 | + | } | |
| 258 | + | pendingRedraw = false | |
| 249 | 259 | lastRedraw = time.Now() | |
| 250 | 260 | i.redraw() | |
| 251 | 261 | return | |
| @@ -257,7 +267,10 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 257 | 267 | wait := redrawMinInterval - since | |
| 258 | 268 | if pendingTimer == nil { | |
| 259 | 269 | pendingTimer = time.AfterFunc(wait, func() { | |
| 260 | - | // Poke the dirty channel; the main loop will call drainPending(). | |
| 270 | + | // Poke the dirty channel so the main loop wakes and | |
| 271 | + | // drains the pending redraw on its own goroutine. We | |
| 272 | + | // can't call drainPending here directly — it touches | |
| 273 | + | // closure state shared with the main loop. | |
| 261 | 274 | i.invalidate() | |
| 262 | 275 | }) | |
| 263 | 276 | } else { | |
| @@ -292,9 +305,15 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 292 | 305 | case <-i.dirty: | |
| 293 | 306 | requestRedraw() | |
| 294 | 307 | case <-tick.C: | |
| 308 | + | // Always drain a pending redraw on the tick. This is the | |
| 309 | + | // safety net that catches the case where the dirty channel | |
| 310 | + | // was saturated when the final "turn finished" invalidate | |
| 311 | + | // fired, or where the throttle scheduled a deferred redraw | |
| 312 | + | // and the AfterFunc-driven invalidate got dropped on a | |
| 313 | + | // full channel. | |
| 314 | + | drainPending() | |
| 295 | 315 | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() { | |
| 296 | - | drainPending() | |
| 297 | - | requestRedraw() | |
| 316 | + | requestRedraw() // keep the spinner / dialog animation moving | |
| 298 | 317 | } | |
| 299 | 318 | } | |
| 300 | 319 | } | |
| @@ -246,6 +246,16 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 246 | requestRedraw := func() { | 246 | requestRedraw := func() { | ||
| 247 | since := time.Since(lastRedraw) | 247 | since := time.Since(lastRedraw) | ||
| 248 | if since >= redrawMinInterval { | 248 | if since >= redrawMinInterval { | ||
| 249 | + | // Redrawing right now subsumes any pending redraw, so clear | |||
| 250 | + | // the throttle state. Without this, a pending flag stays | |||
| 251 | + | // stuck at true and subsequent invalidate() calls within | |||
| 252 | + | // redrawMinInterval get dropped — which is exactly how the | |||
| 253 | + | // final "turn finished" frame went missing until the user | |||
| 254 | + | // nudged the ui by typing or scrolling. | |||
| 255 | + | if pendingTimer != nil { | |||
| 256 | + | pendingTimer.Stop() | |||
| 257 | + | } | |||
| 258 | + | pendingRedraw = false | |||
| 249 | lastRedraw = time.Now() | 259 | lastRedraw = time.Now() | ||
| 250 | i.redraw() | 260 | i.redraw() | ||
| 251 | return | 261 | return | ||
| @@ -257,7 +267,10 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 257 | wait := redrawMinInterval - since | 267 | wait := redrawMinInterval - since | ||
| 258 | if pendingTimer == nil { | 268 | if pendingTimer == nil { | ||
| 259 | pendingTimer = time.AfterFunc(wait, func() { | 269 | pendingTimer = time.AfterFunc(wait, func() { | ||
| 260 | - | // Poke the dirty channel; the main loop will call drainPending(). | 270 | + | // Poke the dirty channel so the main loop wakes and |
| 271 | + | // drains the pending redraw on its own goroutine. We | |||
| 272 | + | // can't call drainPending here directly — it touches | |||
| 273 | + | // closure state shared with the main loop. | |||
| 261 | i.invalidate() | 274 | i.invalidate() | ||
| 262 | }) | 275 | }) | ||
| 263 | } else { | 276 | } else { | ||
| @@ -292,9 +305,15 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 292 | case <-i.dirty: | 305 | case <-i.dirty: | ||
| 293 | requestRedraw() | 306 | requestRedraw() | ||
| 294 | case <-tick.C: | 307 | case <-tick.C: | ||
| 308 | + | // Always drain a pending redraw on the tick. This is the | |||
| 309 | + | // safety net that catches the case where the dirty channel | |||
| 310 | + | // was saturated when the final "turn finished" invalidate | |||
| 311 | + | // fired, or where the throttle scheduled a deferred redraw | |||
| 312 | + | // and the AfterFunc-driven invalidate got dropped on a | |||
| 313 | + | // full channel. | |||
| 314 | + | drainPending() | |||
| 295 | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() { | 315 | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() { | ||
| 296 | - | drainPending() | 316 | + | requestRedraw() // keep the spinner / dialog animation moving |
| 297 | - | requestRedraw() | |||
| 298 | } | 317 | } | ||
| 299 | } | 318 | } | ||
| 300 | } | 319 | } | ||