fix(tui): throttle tool event redraws
- commit
- 3bed82790621d4993c669e2dab8d62a932785f5b
- author
- brice · · 2026-08-08 03:54 UTC
- parent
- ab30a57
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +31 -4
M packages/agent/modes/interactive.go +3 -4
| @@ -1343,10 +1343,9 @@ func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |||
| 1343 | 1343 | ||
| 1344 | 1344 | func (i *Interactive) invalidate() { | |
| 1345 | 1345 | i.renderRevision.Add(1) | |
| 1346 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1347 | - | scheduler.request(false, false) | |
| 1348 | - | return | |
| 1349 | - | } | |
| 1346 | + | // Keep ordinary state changes on the main-loop throttle. The scheduler | |
| 1347 | + | // owns terminal output, but scheduling it here would bypass | |
| 1348 | + | // redrawMinInterval and paint every intermediate tool-event frame. | |
| 1350 | 1349 | select { | |
| 1351 | 1350 | case i.dirty <- struct{}{}: | |
| 1352 | 1351 | default: | |
| @@ -1343,10 +1343,9 @@ func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |||||
| 1343 | 1343 | ||||
| 1344 | func (i *Interactive) invalidate() { | 1344 | func (i *Interactive) invalidate() { | ||
| 1345 | i.renderRevision.Add(1) | 1345 | i.renderRevision.Add(1) | ||
| 1346 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | 1346 | + | // Keep ordinary state changes on the main-loop throttle. The scheduler |
| 1347 | - | scheduler.request(false, false) | 1347 | + | // owns terminal output, but scheduling it here would bypass |
| 1348 | - | return | 1348 | + | // redrawMinInterval and paint every intermediate tool-event frame. |
| 1349 | - | } | |||
| 1350 | select { | 1349 | select { | ||
| 1351 | case i.dirty <- struct{}{}: | 1350 | case i.dirty <- struct{}{}: | ||
| 1352 | default: | 1351 | default: | ||
M packages/agent/modes/interactive_render_test.go +28 -0
| @@ -65,6 +65,34 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) { | |||
| 65 | 65 | } | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) { | |
| 69 | + | scheduler := newLatestFrameScheduler() | |
| 70 | + | i := &Interactive{ | |
| 71 | + | dirty: make(chan struct{}, 1), | |
| 72 | + | toolCalls: make(map[string]*tui.ToolCallView), | |
| 73 | + | toolGate: make(map[string]int), | |
| 74 | + | } | |
| 75 | + | i.renderScheduler.Store(scheduler) | |
| 76 | + | ||
| 77 | + | i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"}) | |
| 78 | + | i.handleEventForPresentation(core.EvToolUseArgs{ID: "call", Delta: `{"command":"pwd"}`}) | |
| 79 | + | i.handleEventForPresentation(core.EvToolUseEnd{ID: "call"}) | |
| 80 | + | ||
| 81 | + | if got := len(scheduler.wake); got != 0 { | |
| 82 | + | t.Fatalf("tool events bypassed the main-loop redraw throttle: %d direct request", got) | |
| 83 | + | } | |
| 84 | + | select { | |
| 85 | + | case <-i.dirty: | |
| 86 | + | default: | |
| 87 | + | t.Fatal("tool events did not wake the throttled redraw path") | |
| 88 | + | } | |
| 89 | + | select { | |
| 90 | + | case <-i.dirty: | |
| 91 | + | t.Fatal("tool event burst queued more than one throttled redraw") | |
| 92 | + | default: | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 68 | 96 | func TestStableChatCacheTracksViewInvalidation(t *testing.T) { | |
| 69 | 97 | i := &Interactive{ | |
| 70 | 98 | view: &tui.View{ | |
| @@ -65,6 +65,34 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) { | |||||
| 65 | } | 65 | } | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | + | func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) { | |||
| 69 | + | scheduler := newLatestFrameScheduler() | |||
| 70 | + | i := &Interactive{ | |||
| 71 | + | dirty: make(chan struct{}, 1), | |||
| 72 | + | toolCalls: make(map[string]*tui.ToolCallView), | |||
| 73 | + | toolGate: make(map[string]int), | |||
| 74 | + | } | |||
| 75 | + | i.renderScheduler.Store(scheduler) | |||
| 76 | + | ||||
| 77 | + | i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"}) | |||
| 78 | + | i.handleEventForPresentation(core.EvToolUseArgs{ID: "call", Delta: `{"command":"pwd"}`}) | |||
| 79 | + | i.handleEventForPresentation(core.EvToolUseEnd{ID: "call"}) | |||
| 80 | + | ||||
| 81 | + | if got := len(scheduler.wake); got != 0 { | |||
| 82 | + | t.Fatalf("tool events bypassed the main-loop redraw throttle: %d direct request", got) | |||
| 83 | + | } | |||
| 84 | + | select { | |||
| 85 | + | case <-i.dirty: | |||
| 86 | + | default: | |||
| 87 | + | t.Fatal("tool events did not wake the throttled redraw path") | |||
| 88 | + | } | |||
| 89 | + | select { | |||
| 90 | + | case <-i.dirty: | |||
| 91 | + | t.Fatal("tool event burst queued more than one throttled redraw") | |||
| 92 | + | default: | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 68 | func TestStableChatCacheTracksViewInvalidation(t *testing.T) { | 96 | func TestStableChatCacheTracksViewInvalidation(t *testing.T) { | ||
| 69 | i := &Interactive{ | 97 | i := &Interactive{ | ||
| 70 | view: &tui.View{ | 98 | view: &tui.View{ | ||