fix(tui): force repaint on layout-shifting events
Calls Invalidate() on tool result, assistant message commit, and turn end to prevent stale code fragments from bleeding into the status bar when the diff renderer misses row changes.
- commit
- 9cc2fd6e8acc00d84555ac1937a061e036ca745f
- author
- patriceckhart · · 2026-04-24 13:00 UTC
- parent
- b25a2bc
- signature
- unsigned
- refs
- tag: v0.1.7
1 file · +16 -4
M internal/agent/modes/interactive.go +16 -4
| @@ -2598,6 +2598,10 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 2598 | 2598 | return | |
| 2599 | 2599 | } | |
| 2600 | 2600 | i.resetStreamingStateLocked() | |
| 2601 | + | // Force full repaint: assistant text committed, layout shifts. | |
| 2602 | + | if i.rend != nil { | |
| 2603 | + | i.rend.Invalidate() | |
| 2604 | + | } | |
| 2601 | 2605 | case core.EvToolUseStart: | |
| 2602 | 2606 | // Live streaming: pre-create the view so the user sees the | |
| 2603 | 2607 | // tool call being composed in real time. Any subsequent | |
| @@ -2657,6 +2661,12 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 2657 | 2661 | } | |
| 2658 | 2662 | tc.Result = text.String() | |
| 2659 | 2663 | } | |
| 2664 | + | // Force full repaint: tool completion shifts the layout | |
| 2665 | + | // (collapsed body replaces streaming) and the diff renderer | |
| 2666 | + | // may leave stale code fragments on status bar rows. | |
| 2667 | + | if i.rend != nil { | |
| 2668 | + | i.rend.Invalidate() | |
| 2669 | + | } | |
| 2660 | 2670 | if i.cfg.OnToolResult != nil { | |
| 2661 | 2671 | i.cfg.OnToolResult(e.ID, e.Result) | |
| 2662 | 2672 | } | |
| @@ -2666,11 +2676,13 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 2666 | 2676 | i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | |
| 2667 | 2677 | } | |
| 2668 | 2678 | case core.EvTurnEnd: | |
| 2679 | + | // Force full repaint: the turn ending shifts layout (streaming | |
| 2680 | + | // buffer flushed, status bar updates) and the diff renderer | |
| 2681 | + | // may leave stale content on reused rows. | |
| 2682 | + | if i.rend != nil { | |
| 2683 | + | i.rend.Invalidate() | |
| 2684 | + | } | |
| 2669 | 2685 | if e.Stop == provider.StopAborted { | |
| 2670 | - | // Aborted turn: discard the partial streaming text (it | |
| 2671 | - | // is not persisted in the transcript) and clear any | |
| 2672 | - | // transient error. Also drop anything still buffered in | |
| 2673 | - | // the pacer so nothing keeps drawing after the cancel. | |
| 2674 | 2686 | i.resetStreamingStateLocked() | |
| 2675 | 2687 | i.statusErr = "" | |
| 2676 | 2688 | i.statusOK = "cancelled" | |
| @@ -2598,6 +2598,10 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 2598 | return | 2598 | return | ||
| 2599 | } | 2599 | } | ||
| 2600 | i.resetStreamingStateLocked() | 2600 | i.resetStreamingStateLocked() | ||
| 2601 | + | // Force full repaint: assistant text committed, layout shifts. | |||
| 2602 | + | if i.rend != nil { | |||
| 2603 | + | i.rend.Invalidate() | |||
| 2604 | + | } | |||
| 2601 | case core.EvToolUseStart: | 2605 | case core.EvToolUseStart: | ||
| 2602 | // Live streaming: pre-create the view so the user sees the | 2606 | // Live streaming: pre-create the view so the user sees the | ||
| 2603 | // tool call being composed in real time. Any subsequent | 2607 | // tool call being composed in real time. Any subsequent | ||
| @@ -2657,6 +2661,12 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 2657 | } | 2661 | } | ||
| 2658 | tc.Result = text.String() | 2662 | tc.Result = text.String() | ||
| 2659 | } | 2663 | } | ||
| 2664 | + | // Force full repaint: tool completion shifts the layout | |||
| 2665 | + | // (collapsed body replaces streaming) and the diff renderer | |||
| 2666 | + | // may leave stale code fragments on status bar rows. | |||
| 2667 | + | if i.rend != nil { | |||
| 2668 | + | i.rend.Invalidate() | |||
| 2669 | + | } | |||
| 2660 | if i.cfg.OnToolResult != nil { | 2670 | if i.cfg.OnToolResult != nil { | ||
| 2661 | i.cfg.OnToolResult(e.ID, e.Result) | 2671 | i.cfg.OnToolResult(e.ID, e.Result) | ||
| 2662 | } | 2672 | } | ||
| @@ -2666,11 +2676,13 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 2666 | i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | 2676 | i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | ||
| 2667 | } | 2677 | } | ||
| 2668 | case core.EvTurnEnd: | 2678 | case core.EvTurnEnd: | ||
| 2679 | + | // Force full repaint: the turn ending shifts layout (streaming | |||
| 2680 | + | // buffer flushed, status bar updates) and the diff renderer | |||
| 2681 | + | // may leave stale content on reused rows. | |||
| 2682 | + | if i.rend != nil { | |||
| 2683 | + | i.rend.Invalidate() | |||
| 2684 | + | } | |||
| 2669 | if e.Stop == provider.StopAborted { | 2685 | if e.Stop == provider.StopAborted { | ||
| 2670 | - | // Aborted turn: discard the partial streaming text (it | |||
| 2671 | - | // is not persisted in the transcript) and clear any | |||
| 2672 | - | // transient error. Also drop anything still buffered in | |||
| 2673 | - | // the pacer so nothing keeps drawing after the cancel. | |||
| 2674 | i.resetStreamingStateLocked() | 2686 | i.resetStreamingStateLocked() | ||
| 2675 | i.statusErr = "" | 2687 | i.statusErr = "" | ||
| 2676 | i.statusOK = "cancelled" | 2688 | i.statusOK = "cancelled" | ||