detached 75ed9d8

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 {
246246 requestRedraw := func() {
247247 since := time.Since(lastRedraw)
248248 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
249259 lastRedraw = time.Now()
250260 i.redraw()
251261 return
@@ -257,7 +267,10 @@ func (i *Interactive) Run(ctx context.Context) error {
257267 wait := redrawMinInterval - since
258268 if pendingTimer == nil {
259269 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.
261274 i.invalidate()
262275 })
263276 } else {
@@ -292,9 +305,15 @@ func (i *Interactive) Run(ctx context.Context) error {
292305 case <-i.dirty:
293306 requestRedraw()
294307 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()
295315 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
298317 }
299318 }
300319 }
@@ -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 return261 return
@@ -257,7 +267,10 @@ func (i *Interactive) Run(ctx context.Context) error {
257 wait := redrawMinInterval - since267 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 }

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