Merge pull request #74 from bnema/fix/tui-vertical-shake
fix(tui): throttle tool event redraws
2 files · +31 -4
M packages/agent/modes/interactive.go +3 -4
| @@ -1342,10 +1342,9 @@ func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |||
| 1342 | 1342 | ||
| 1343 | 1343 | func (i *Interactive) invalidate() { | |
| 1344 | 1344 | i.renderRevision.Add(1) | |
| 1345 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1346 | - | scheduler.request(false, false) | |
| 1347 | - | return | |
| 1348 | - | } | |
| 1345 | + | // Keep ordinary state changes on the main-loop throttle. The scheduler | |
| 1346 | + | // owns terminal output, but scheduling it here would bypass | |
| 1347 | + | // redrawMinInterval and paint every intermediate tool-event frame. | |
| 1349 | 1348 | select { | |
| 1350 | 1349 | case i.dirty <- struct{}{}: | |
| 1351 | 1350 | default: | |
| @@ -1342,10 +1342,9 @@ func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |||||
| 1342 | 1342 | ||||
| 1343 | func (i *Interactive) invalidate() { | 1343 | func (i *Interactive) invalidate() { | ||
| 1344 | i.renderRevision.Add(1) | 1344 | i.renderRevision.Add(1) | ||
| 1345 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | 1345 | + | // Keep ordinary state changes on the main-loop throttle. The scheduler |
| 1346 | - | scheduler.request(false, false) | 1346 | + | // owns terminal output, but scheduling it here would bypass |
| 1347 | - | return | 1347 | + | // redrawMinInterval and paint every intermediate tool-event frame. |
| 1348 | - | } | |||
| 1349 | select { | 1348 | select { | ||
| 1350 | case i.dirty <- struct{}{}: | 1349 | case i.dirty <- struct{}{}: | ||
| 1351 | default: | 1350 | 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{ | ||