Merge pull request #102 from bnema/fix/live-edit-render-cache
fix(tui): isolate live tool render frames
4 files · +163 -34
M packages/agent/modes/interactive.go +24 -13
| @@ -654,6 +654,10 @@ type Interactive struct { | |||
| 654 | 654 | streamFlushPending bool | |
| 655 | 655 | toolCalls map[string]*tui.ToolCallView | |
| 656 | 656 | toolOrder []string | |
| 657 | + | // toolRenderRevision is monotonic for the lifetime of the interactive | |
| 658 | + | // view. Per-call counters would let unrelated tools alias the same rendered | |
| 659 | + | // cache frame when they reach the same local revision. | |
| 660 | + | toolRenderRevision uint64 | |
| 657 | 661 | // toolGate records, per tool-call id, how many runes of paced | |
| 658 | 662 | // assistant text must have drained into `streaming` before that | |
| 659 | 663 | // tool block may appear. It exists to make stream ordering | |
| @@ -8164,12 +8168,17 @@ func eventAffectsPresentation(ev core.AgentEvent) bool { | |||
| 8164 | 8168 | } | |
| 8165 | 8169 | } | |
| 8166 | 8170 | ||
| 8167 | - | func bumpToolRevision(tc *tui.ToolCallView) { | |
| 8168 | - | if tc.Revision == ^uint64(0) { | |
| 8169 | - | tc.Revision = 1 | |
| 8170 | - | return | |
| 8171 | + | func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) { | |
| 8172 | + | i.toolRenderRevision++ | |
| 8173 | + | if i.toolRenderRevision == 0 { | |
| 8174 | + | // Overflow is practically unreachable, but resetting the sequence must | |
| 8175 | + | // also drop old revisions before they can alias new frames. | |
| 8176 | + | if i.view != nil { | |
| 8177 | + | i.view.InvalidateRenderCache() | |
| 8178 | + | } | |
| 8179 | + | i.toolRenderRevision = 1 | |
| 8171 | 8180 | } | |
| 8172 | - | tc.Revision++ | |
| 8181 | + | tc.Revision = i.toolRenderRevision | |
| 8173 | 8182 | } | |
| 8174 | 8183 | ||
| 8175 | 8184 | func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) { | |
| @@ -8234,19 +8243,20 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8234 | 8243 | // EvToolCall for the same ID updates the same struct (the | |
| 8235 | 8244 | // final parsed args + name are already known here). | |
| 8236 | 8245 | if _, exists := i.toolCalls[e.ID]; !exists { | |
| 8237 | - | i.toolCalls[e.ID] = &tui.ToolCallView{ | |
| 8246 | + | tc := &tui.ToolCallView{ | |
| 8238 | 8247 | ID: e.ID, | |
| 8239 | 8248 | Name: e.Name, | |
| 8240 | - | Revision: 1, | |
| 8241 | 8249 | Streaming: true, | |
| 8242 | 8250 | } | |
| 8251 | + | i.bumpToolRevisionLocked(tc) | |
| 8252 | + | i.toolCalls[e.ID] = tc | |
| 8243 | 8253 | i.toolOrder = append(i.toolOrder, e.ID) | |
| 8244 | 8254 | i.gateToolLocked(e.ID) | |
| 8245 | 8255 | } | |
| 8246 | 8256 | case core.EvToolUseArgs: | |
| 8247 | 8257 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8248 | 8258 | tc.RawJSONBuf += e.Delta | |
| 8249 | - | bumpToolRevision(tc) | |
| 8259 | + | i.bumpToolRevisionLocked(tc) | |
| 8250 | 8260 | // Refresh the live path as soon as it parses; used in | |
| 8251 | 8261 | // the header (write /Users/example/Desktop/demo.ts) | |
| 8252 | 8262 | // while the content is still streaming. | |
| @@ -8259,7 +8269,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8259 | 8269 | case core.EvToolUseEnd: | |
| 8260 | 8270 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8261 | 8271 | tc.Streaming = false | |
| 8262 | - | bumpToolRevision(tc) | |
| 8272 | + | i.bumpToolRevisionLocked(tc) | |
| 8263 | 8273 | } | |
| 8264 | 8274 | case core.EvToolCall: | |
| 8265 | 8275 | // If we already pre-created the view during streaming, just | |
| @@ -8267,19 +8277,20 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8267 | 8277 | // (non-streaming providers or legacy paths). | |
| 8268 | 8278 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8269 | 8279 | tc.Args = tui.ShortArgs(e.Name, e.Args) | |
| 8270 | - | bumpToolRevision(tc) | |
| 8280 | + | i.bumpToolRevisionLocked(tc) | |
| 8271 | 8281 | if tc.RawJSONBuf == "" { | |
| 8272 | 8282 | tc.RawJSONBuf = string(e.Args) | |
| 8273 | 8283 | } | |
| 8274 | 8284 | tc.Streaming = false | |
| 8275 | 8285 | } else { | |
| 8276 | - | i.toolCalls[e.ID] = &tui.ToolCallView{ | |
| 8286 | + | tc := &tui.ToolCallView{ | |
| 8277 | 8287 | ID: e.ID, | |
| 8278 | 8288 | Name: e.Name, | |
| 8279 | - | Revision: 1, | |
| 8280 | 8289 | Args: tui.ShortArgs(e.Name, e.Args), | |
| 8281 | 8290 | RawJSONBuf: string(e.Args), | |
| 8282 | 8291 | } | |
| 8292 | + | i.bumpToolRevisionLocked(tc) | |
| 8293 | + | i.toolCalls[e.ID] = tc | |
| 8283 | 8294 | i.toolOrder = append(i.toolOrder, e.ID) | |
| 8284 | 8295 | i.gateToolLocked(e.ID) | |
| 8285 | 8296 | } | |
| @@ -8298,7 +8309,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8298 | 8309 | } | |
| 8299 | 8310 | } | |
| 8300 | 8311 | tc.Result = text.String() | |
| 8301 | - | bumpToolRevision(tc) | |
| 8312 | + | i.bumpToolRevisionLocked(tc) | |
| 8302 | 8313 | } | |
| 8303 | 8314 | if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive { | |
| 8304 | 8315 | i.goalStatus = update.Status | |
| @@ -654,6 +654,10 @@ type Interactive struct { | |||||
| 654 | streamFlushPending bool | 654 | streamFlushPending bool | ||
| 655 | toolCalls map[string]*tui.ToolCallView | 655 | toolCalls map[string]*tui.ToolCallView | ||
| 656 | toolOrder []string | 656 | toolOrder []string | ||
| 657 | + | // toolRenderRevision is monotonic for the lifetime of the interactive | |||
| 658 | + | // view. Per-call counters would let unrelated tools alias the same rendered | |||
| 659 | + | // cache frame when they reach the same local revision. | |||
| 660 | + | toolRenderRevision uint64 | |||
| 657 | // toolGate records, per tool-call id, how many runes of paced | 661 | // toolGate records, per tool-call id, how many runes of paced | ||
| 658 | // assistant text must have drained into `streaming` before that | 662 | // assistant text must have drained into `streaming` before that | ||
| 659 | // tool block may appear. It exists to make stream ordering | 663 | // tool block may appear. It exists to make stream ordering | ||
| @@ -8164,12 +8168,17 @@ func eventAffectsPresentation(ev core.AgentEvent) bool { | |||||
| 8164 | } | 8168 | } | ||
| 8165 | } | 8169 | } | ||
| 8166 | 8170 | ||||
| 8167 | - | func bumpToolRevision(tc *tui.ToolCallView) { | 8171 | + | func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) { |
| 8168 | - | if tc.Revision == ^uint64(0) { | 8172 | + | i.toolRenderRevision++ |
| 8169 | - | tc.Revision = 1 | 8173 | + | if i.toolRenderRevision == 0 { |
| 8170 | - | return | 8174 | + | // Overflow is practically unreachable, but resetting the sequence must |
| 8175 | + | // also drop old revisions before they can alias new frames. | |||
| 8176 | + | if i.view != nil { | |||
| 8177 | + | i.view.InvalidateRenderCache() | |||
| 8178 | + | } | |||
| 8179 | + | i.toolRenderRevision = 1 | |||
| 8171 | } | 8180 | } | ||
| 8172 | - | tc.Revision++ | 8181 | + | tc.Revision = i.toolRenderRevision |
| 8173 | } | 8182 | } | ||
| 8174 | 8183 | ||||
| 8175 | func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) { | 8184 | func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) { | ||
| @@ -8234,19 +8243,20 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8234 | // EvToolCall for the same ID updates the same struct (the | 8243 | // EvToolCall for the same ID updates the same struct (the | ||
| 8235 | // final parsed args + name are already known here). | 8244 | // final parsed args + name are already known here). | ||
| 8236 | if _, exists := i.toolCalls[e.ID]; !exists { | 8245 | if _, exists := i.toolCalls[e.ID]; !exists { | ||
| 8237 | - | i.toolCalls[e.ID] = &tui.ToolCallView{ | 8246 | + | tc := &tui.ToolCallView{ |
| 8238 | ID: e.ID, | 8247 | ID: e.ID, | ||
| 8239 | Name: e.Name, | 8248 | Name: e.Name, | ||
| 8240 | - | Revision: 1, | |||
| 8241 | Streaming: true, | 8249 | Streaming: true, | ||
| 8242 | } | 8250 | } | ||
| 8251 | + | i.bumpToolRevisionLocked(tc) | |||
| 8252 | + | i.toolCalls[e.ID] = tc | |||
| 8243 | i.toolOrder = append(i.toolOrder, e.ID) | 8253 | i.toolOrder = append(i.toolOrder, e.ID) | ||
| 8244 | i.gateToolLocked(e.ID) | 8254 | i.gateToolLocked(e.ID) | ||
| 8245 | } | 8255 | } | ||
| 8246 | case core.EvToolUseArgs: | 8256 | case core.EvToolUseArgs: | ||
| 8247 | if tc, ok := i.toolCalls[e.ID]; ok { | 8257 | if tc, ok := i.toolCalls[e.ID]; ok { | ||
| 8248 | tc.RawJSONBuf += e.Delta | 8258 | tc.RawJSONBuf += e.Delta | ||
| 8249 | - | bumpToolRevision(tc) | 8259 | + | i.bumpToolRevisionLocked(tc) |
| 8250 | // Refresh the live path as soon as it parses; used in | 8260 | // Refresh the live path as soon as it parses; used in | ||
| 8251 | // the header (write /Users/example/Desktop/demo.ts) | 8261 | // the header (write /Users/example/Desktop/demo.ts) | ||
| 8252 | // while the content is still streaming. | 8262 | // while the content is still streaming. | ||
| @@ -8259,7 +8269,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8259 | case core.EvToolUseEnd: | 8269 | case core.EvToolUseEnd: | ||
| 8260 | if tc, ok := i.toolCalls[e.ID]; ok { | 8270 | if tc, ok := i.toolCalls[e.ID]; ok { | ||
| 8261 | tc.Streaming = false | 8271 | tc.Streaming = false | ||
| 8262 | - | bumpToolRevision(tc) | 8272 | + | i.bumpToolRevisionLocked(tc) |
| 8263 | } | 8273 | } | ||
| 8264 | case core.EvToolCall: | 8274 | case core.EvToolCall: | ||
| 8265 | // If we already pre-created the view during streaming, just | 8275 | // If we already pre-created the view during streaming, just | ||
| @@ -8267,19 +8277,20 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8267 | // (non-streaming providers or legacy paths). | 8277 | // (non-streaming providers or legacy paths). | ||
| 8268 | if tc, ok := i.toolCalls[e.ID]; ok { | 8278 | if tc, ok := i.toolCalls[e.ID]; ok { | ||
| 8269 | tc.Args = tui.ShortArgs(e.Name, e.Args) | 8279 | tc.Args = tui.ShortArgs(e.Name, e.Args) | ||
| 8270 | - | bumpToolRevision(tc) | 8280 | + | i.bumpToolRevisionLocked(tc) |
| 8271 | if tc.RawJSONBuf == "" { | 8281 | if tc.RawJSONBuf == "" { | ||
| 8272 | tc.RawJSONBuf = string(e.Args) | 8282 | tc.RawJSONBuf = string(e.Args) | ||
| 8273 | } | 8283 | } | ||
| 8274 | tc.Streaming = false | 8284 | tc.Streaming = false | ||
| 8275 | } else { | 8285 | } else { | ||
| 8276 | - | i.toolCalls[e.ID] = &tui.ToolCallView{ | 8286 | + | tc := &tui.ToolCallView{ |
| 8277 | ID: e.ID, | 8287 | ID: e.ID, | ||
| 8278 | Name: e.Name, | 8288 | Name: e.Name, | ||
| 8279 | - | Revision: 1, | |||
| 8280 | Args: tui.ShortArgs(e.Name, e.Args), | 8289 | Args: tui.ShortArgs(e.Name, e.Args), | ||
| 8281 | RawJSONBuf: string(e.Args), | 8290 | RawJSONBuf: string(e.Args), | ||
| 8282 | } | 8291 | } | ||
| 8292 | + | i.bumpToolRevisionLocked(tc) | |||
| 8293 | + | i.toolCalls[e.ID] = tc | |||
| 8283 | i.toolOrder = append(i.toolOrder, e.ID) | 8294 | i.toolOrder = append(i.toolOrder, e.ID) | ||
| 8284 | i.gateToolLocked(e.ID) | 8295 | i.gateToolLocked(e.ID) | ||
| 8285 | } | 8296 | } | ||
| @@ -8298,7 +8309,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8298 | } | 8309 | } | ||
| 8299 | } | 8310 | } | ||
| 8300 | tc.Result = text.String() | 8311 | tc.Result = text.String() | ||
| 8301 | - | bumpToolRevision(tc) | 8312 | + | i.bumpToolRevisionLocked(tc) |
| 8302 | } | 8313 | } | ||
| 8303 | if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive { | 8314 | if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive { | ||
| 8304 | i.goalStatus = update.Status | 8315 | i.goalStatus = update.Status | ||
M packages/agent/modes/interactive_render_test.go +22 -0
| @@ -65,6 +65,28 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) { | |||
| 65 | 65 | } | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) { | |
| 69 | + | i := &Interactive{ | |
| 70 | + | dirty: make(chan struct{}, 1), | |
| 71 | + | toolCalls: make(map[string]*tui.ToolCallView), | |
| 72 | + | toolGate: make(map[string]int), | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"}) | |
| 76 | + | alphaStart := i.toolCalls["alpha"].Revision | |
| 77 | + | i.handleEventForPresentation(core.EvToolUseArgs{ID: "alpha", Delta: `{"path":"alpha.go"}`}) | |
| 78 | + | alphaArgs := i.toolCalls["alpha"].Revision | |
| 79 | + | i.handleEventForPresentation(core.EvToolUseStart{ID: "beta", Name: "edit"}) | |
| 80 | + | betaStart := i.toolCalls["beta"].Revision | |
| 81 | + | i.handleEventForPresentation(core.EvToolUseArgs{ID: "beta", Delta: `{"path":"beta.go"}`}) | |
| 82 | + | betaArgs := i.toolCalls["beta"].Revision | |
| 83 | + | ||
| 84 | + | if alphaStart >= alphaArgs || alphaArgs >= betaStart || betaStart >= betaArgs { | |
| 85 | + | t.Fatalf("tool revisions are not globally monotonic: alpha start=%d args=%d, beta start=%d args=%d", | |
| 86 | + | alphaStart, alphaArgs, betaStart, betaArgs) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 68 | 90 | func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) { | |
| 69 | 91 | scheduler := newLatestFrameScheduler() | |
| 70 | 92 | i := &Interactive{ | |
| @@ -65,6 +65,28 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) { | |||||
| 65 | } | 65 | } | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | + | func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) { | |||
| 69 | + | i := &Interactive{ | |||
| 70 | + | dirty: make(chan struct{}, 1), | |||
| 71 | + | toolCalls: make(map[string]*tui.ToolCallView), | |||
| 72 | + | toolGate: make(map[string]int), | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"}) | |||
| 76 | + | alphaStart := i.toolCalls["alpha"].Revision | |||
| 77 | + | i.handleEventForPresentation(core.EvToolUseArgs{ID: "alpha", Delta: `{"path":"alpha.go"}`}) | |||
| 78 | + | alphaArgs := i.toolCalls["alpha"].Revision | |||
| 79 | + | i.handleEventForPresentation(core.EvToolUseStart{ID: "beta", Name: "edit"}) | |||
| 80 | + | betaStart := i.toolCalls["beta"].Revision | |||
| 81 | + | i.handleEventForPresentation(core.EvToolUseArgs{ID: "beta", Delta: `{"path":"beta.go"}`}) | |||
| 82 | + | betaArgs := i.toolCalls["beta"].Revision | |||
| 83 | + | ||||
| 84 | + | if alphaStart >= alphaArgs || alphaArgs >= betaStart || betaStart >= betaArgs { | |||
| 85 | + | t.Fatalf("tool revisions are not globally monotonic: alpha start=%d args=%d, beta start=%d args=%d", | |||
| 86 | + | alphaStart, alphaArgs, betaStart, betaArgs) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 68 | func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) { | 90 | func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) { | ||
| 69 | scheduler := newLatestFrameScheduler() | 91 | scheduler := newLatestFrameScheduler() | ||
| 70 | i := &Interactive{ | 92 | i := &Interactive{ | ||
M packages/tui/view.go +43 -21
| @@ -234,6 +234,7 @@ type messageRenderCacheEntry struct { | |||
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | 236 | type liveToolCacheKey struct { | |
| 237 | + | callID string | |
| 237 | 238 | revision uint64 | |
| 238 | 239 | content uint64 | |
| 239 | 240 | width int | |
| @@ -390,7 +391,7 @@ func (v *View) Build(width int) []string { | |||
| 390 | 391 | // scrollback renderers can keep these rows outside the immutable transcript | |
| 391 | 392 | // so native scrolling stays stable while a turn streams. | |
| 392 | 393 | func (v *View) BuildLive(width int) []string { | |
| 393 | - | v.prepareLiveBodyHeights() | |
| 394 | + | v.prepareLiveToolState() | |
| 394 | 395 | if v.liveRenderCache == nil { | |
| 395 | 396 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |
| 396 | 397 | } | |
| @@ -531,22 +532,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 531 | 532 | if v.liveRenderCache == nil { | |
| 532 | 533 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |
| 533 | 534 | } | |
| 534 | - | v.prepareLiveBodyHeights() | |
| 535 | - | // Drop high-water entries for calls that are gone or finalised so | |
| 536 | - | // the reservation never leaks into a later, shorter tool box. | |
| 537 | - | if len(v.liveBodyHigh) > 0 { | |
| 538 | - | active := make(map[string]bool, len(v.ToolCalls)) | |
| 539 | - | for _, tc := range v.ToolCalls { | |
| 540 | - | if tc.Result == "" { | |
| 541 | - | active[tc.ID] = true | |
| 542 | - | } | |
| 543 | - | } | |
| 544 | - | for id := range v.liveBodyHigh { | |
| 545 | - | if !active[id] { | |
| 546 | - | delete(v.liveBodyHigh, id) | |
| 547 | - | } | |
| 548 | - | } | |
| 549 | - | } | |
| 535 | + | v.prepareLiveToolState() | |
| 550 | 536 | ||
| 551 | 537 | // Pre-render every message (hits the cache for unchanged ones) so | |
| 552 | 538 | // we can allocate `out` in a single shot with the exact capacity. | |
| @@ -665,14 +651,42 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 665 | 651 | return out, anchors | |
| 666 | 652 | } | |
| 667 | 653 | ||
| 668 | - | // prepareLiveBodyHeights resets live tool height reservations when ctrl+o | |
| 669 | - | // changes expansion state. Expanded content must not keep a collapsed box | |
| 670 | - | // padded to its former full height. | |
| 671 | - | func (v *View) prepareLiveBodyHeights() { | |
| 654 | + | // prepareLiveToolState drops cached frames and height reservations that no | |
| 655 | + | // longer match an active tool version. Expansion changes also reset height | |
| 656 | + | // reservations so collapsed dimensions cannot leak into expanded content. | |
| 657 | + | func (v *View) prepareLiveToolState() { | |
| 672 | 658 | if v.liveBodyHigh == nil || v.liveBodyExpandAll != v.ExpandAll { | |
| 673 | 659 | v.liveBodyHigh = make(map[string]int) | |
| 674 | 660 | v.liveBodyExpandAll = v.ExpandAll | |
| 675 | 661 | } | |
| 662 | + | ||
| 663 | + | activeCalls := make(map[string]bool, len(v.ToolCalls)) | |
| 664 | + | activeVersions := make(map[liveToolCacheKey]struct{}, len(v.ToolCalls)) | |
| 665 | + | for _, tc := range v.ToolCalls { | |
| 666 | + | if tc.Result == "" { | |
| 667 | + | activeCalls[tc.ID] = true | |
| 668 | + | } | |
| 669 | + | activeVersions[liveToolCacheKey{ | |
| 670 | + | callID: tc.ID, | |
| 671 | + | revision: tc.Revision, | |
| 672 | + | content: hashLiveTool(tc), | |
| 673 | + | }] = struct{}{} | |
| 674 | + | } | |
| 675 | + | for id := range v.liveBodyHigh { | |
| 676 | + | if !activeCalls[id] { | |
| 677 | + | delete(v.liveBodyHigh, id) | |
| 678 | + | } | |
| 679 | + | } | |
| 680 | + | for key := range v.liveRenderCache { | |
| 681 | + | version := liveToolCacheKey{ | |
| 682 | + | callID: key.callID, | |
| 683 | + | revision: key.revision, | |
| 684 | + | content: key.content, | |
| 685 | + | } | |
| 686 | + | if _, ok := activeVersions[version]; !ok { | |
| 687 | + | delete(v.liveRenderCache, key) | |
| 688 | + | } | |
| 689 | + | } | |
| 676 | 690 | } | |
| 677 | 691 | ||
| 678 | 692 | // refreshToolPaths rebuilds the tool_use_id -> path map from the | |
| @@ -1102,6 +1116,7 @@ func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) | |||
| 1102 | 1116 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |
| 1103 | 1117 | } | |
| 1104 | 1118 | key := liveToolCacheKey{ | |
| 1119 | + | callID: tc.ID, | |
| 1105 | 1120 | revision: tc.Revision, | |
| 1106 | 1121 | content: hashLiveTool(tc), | |
| 1107 | 1122 | width: width, | |
| @@ -1253,6 +1268,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 1253 | 1268 | // high-water mark is per call id and is dropped the moment a | |
| 1254 | 1269 | // Result lands (this branch only runs while Result == ""). | |
| 1255 | 1270 | high := len(body) | |
| 1271 | + | // Live edits otherwise grow from a few rows to the collapsed | |
| 1272 | + | // preview limit as newText arrives, moving the editor on every | |
| 1273 | + | // line. Reserve the final collapsed height from the first visible | |
| 1274 | + | // edit frame; ctrl+o still opts into unconstrained growth. | |
| 1275 | + | if !v.ExpandAll && (tc.Name == "edit" || tc.Name == "Edit") && high < ToolCollapsePreview+2 { | |
| 1276 | + | high = ToolCollapsePreview + 2 | |
| 1277 | + | } | |
| 1256 | 1278 | if v.liveBodyHigh != nil { | |
| 1257 | 1279 | if prev := v.liveBodyHigh[tc.ID]; prev > high { | |
| 1258 | 1280 | high = prev | |
| @@ -234,6 +234,7 @@ type messageRenderCacheEntry struct { | |||||
| 234 | } | 234 | } | ||
| 235 | 235 | ||||
| 236 | type liveToolCacheKey struct { | 236 | type liveToolCacheKey struct { | ||
| 237 | + | callID string | |||
| 237 | revision uint64 | 238 | revision uint64 | ||
| 238 | content uint64 | 239 | content uint64 | ||
| 239 | width int | 240 | width int | ||
| @@ -390,7 +391,7 @@ func (v *View) Build(width int) []string { | |||||
| 390 | // scrollback renderers can keep these rows outside the immutable transcript | 391 | // scrollback renderers can keep these rows outside the immutable transcript | ||
| 391 | // so native scrolling stays stable while a turn streams. | 392 | // so native scrolling stays stable while a turn streams. | ||
| 392 | func (v *View) BuildLive(width int) []string { | 393 | func (v *View) BuildLive(width int) []string { | ||
| 393 | - | v.prepareLiveBodyHeights() | 394 | + | v.prepareLiveToolState() |
| 394 | if v.liveRenderCache == nil { | 395 | if v.liveRenderCache == nil { | ||
| 395 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | 396 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | ||
| 396 | } | 397 | } | ||
| @@ -531,22 +532,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 531 | if v.liveRenderCache == nil { | 532 | if v.liveRenderCache == nil { | ||
| 532 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | 533 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | ||
| 533 | } | 534 | } | ||
| 534 | - | v.prepareLiveBodyHeights() | 535 | + | v.prepareLiveToolState() |
| 535 | - | // Drop high-water entries for calls that are gone or finalised so | |||
| 536 | - | // the reservation never leaks into a later, shorter tool box. | |||
| 537 | - | if len(v.liveBodyHigh) > 0 { | |||
| 538 | - | active := make(map[string]bool, len(v.ToolCalls)) | |||
| 539 | - | for _, tc := range v.ToolCalls { | |||
| 540 | - | if tc.Result == "" { | |||
| 541 | - | active[tc.ID] = true | |||
| 542 | - | } | |||
| 543 | - | } | |||
| 544 | - | for id := range v.liveBodyHigh { | |||
| 545 | - | if !active[id] { | |||
| 546 | - | delete(v.liveBodyHigh, id) | |||
| 547 | - | } | |||
| 548 | - | } | |||
| 549 | - | } | |||
| 550 | 536 | ||||
| 551 | // Pre-render every message (hits the cache for unchanged ones) so | 537 | // Pre-render every message (hits the cache for unchanged ones) so | ||
| 552 | // we can allocate `out` in a single shot with the exact capacity. | 538 | // we can allocate `out` in a single shot with the exact capacity. | ||
| @@ -665,14 +651,42 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 665 | return out, anchors | 651 | return out, anchors | ||
| 666 | } | 652 | } | ||
| 667 | 653 | ||||
| 668 | - | // prepareLiveBodyHeights resets live tool height reservations when ctrl+o | 654 | + | // prepareLiveToolState drops cached frames and height reservations that no |
| 669 | - | // changes expansion state. Expanded content must not keep a collapsed box | 655 | + | // longer match an active tool version. Expansion changes also reset height |
| 670 | - | // padded to its former full height. | 656 | + | // reservations so collapsed dimensions cannot leak into expanded content. |
| 671 | - | func (v *View) prepareLiveBodyHeights() { | 657 | + | func (v *View) prepareLiveToolState() { |
| 672 | if v.liveBodyHigh == nil || v.liveBodyExpandAll != v.ExpandAll { | 658 | if v.liveBodyHigh == nil || v.liveBodyExpandAll != v.ExpandAll { | ||
| 673 | v.liveBodyHigh = make(map[string]int) | 659 | v.liveBodyHigh = make(map[string]int) | ||
| 674 | v.liveBodyExpandAll = v.ExpandAll | 660 | v.liveBodyExpandAll = v.ExpandAll | ||
| 675 | } | 661 | } | ||
| 662 | + | ||||
| 663 | + | activeCalls := make(map[string]bool, len(v.ToolCalls)) | |||
| 664 | + | activeVersions := make(map[liveToolCacheKey]struct{}, len(v.ToolCalls)) | |||
| 665 | + | for _, tc := range v.ToolCalls { | |||
| 666 | + | if tc.Result == "" { | |||
| 667 | + | activeCalls[tc.ID] = true | |||
| 668 | + | } | |||
| 669 | + | activeVersions[liveToolCacheKey{ | |||
| 670 | + | callID: tc.ID, | |||
| 671 | + | revision: tc.Revision, | |||
| 672 | + | content: hashLiveTool(tc), | |||
| 673 | + | }] = struct{}{} | |||
| 674 | + | } | |||
| 675 | + | for id := range v.liveBodyHigh { | |||
| 676 | + | if !activeCalls[id] { | |||
| 677 | + | delete(v.liveBodyHigh, id) | |||
| 678 | + | } | |||
| 679 | + | } | |||
| 680 | + | for key := range v.liveRenderCache { | |||
| 681 | + | version := liveToolCacheKey{ | |||
| 682 | + | callID: key.callID, | |||
| 683 | + | revision: key.revision, | |||
| 684 | + | content: key.content, | |||
| 685 | + | } | |||
| 686 | + | if _, ok := activeVersions[version]; !ok { | |||
| 687 | + | delete(v.liveRenderCache, key) | |||
| 688 | + | } | |||
| 689 | + | } | |||
| 676 | } | 690 | } | ||
| 677 | 691 | ||||
| 678 | // refreshToolPaths rebuilds the tool_use_id -> path map from the | 692 | // refreshToolPaths rebuilds the tool_use_id -> path map from the | ||
| @@ -1102,6 +1116,7 @@ func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) | |||||
| 1102 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | 1116 | v.liveRenderCache = make(map[liveToolCacheKey][]string) | ||
| 1103 | } | 1117 | } | ||
| 1104 | key := liveToolCacheKey{ | 1118 | key := liveToolCacheKey{ | ||
| 1119 | + | callID: tc.ID, | |||
| 1105 | revision: tc.Revision, | 1120 | revision: tc.Revision, | ||
| 1106 | content: hashLiveTool(tc), | 1121 | content: hashLiveTool(tc), | ||
| 1107 | width: width, | 1122 | width: width, | ||
| @@ -1253,6 +1268,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 1253 | // high-water mark is per call id and is dropped the moment a | 1268 | // high-water mark is per call id and is dropped the moment a | ||
| 1254 | // Result lands (this branch only runs while Result == ""). | 1269 | // Result lands (this branch only runs while Result == ""). | ||
| 1255 | high := len(body) | 1270 | high := len(body) | ||
| 1271 | + | // Live edits otherwise grow from a few rows to the collapsed | |||
| 1272 | + | // preview limit as newText arrives, moving the editor on every | |||
| 1273 | + | // line. Reserve the final collapsed height from the first visible | |||
| 1274 | + | // edit frame; ctrl+o still opts into unconstrained growth. | |||
| 1275 | + | if !v.ExpandAll && (tc.Name == "edit" || tc.Name == "Edit") && high < ToolCollapsePreview+2 { | |||
| 1276 | + | high = ToolCollapsePreview + 2 | |||
| 1277 | + | } | |||
| 1256 | if v.liveBodyHigh != nil { | 1278 | if v.liveBodyHigh != nil { | ||
| 1257 | if prev := v.liveBodyHigh[tc.ID]; prev > high { | 1279 | if prev := v.liveBodyHigh[tc.ID]; prev > high { | ||
| 1258 | high = prev | 1280 | high = prev | ||
M packages/tui/view_tool_overlay_test.go +74 -0
| @@ -82,6 +82,61 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | func TestLiveToolCacheSeparatesCallsAtTheSameRevision(t *testing.T) { | |
| 86 | + | v := View{Theme: Dark, ToolCalls: []ToolCallView{ | |
| 87 | + | { | |
| 88 | + | ID: "toolu_alpha", | |
| 89 | + | Name: "edit", | |
| 90 | + | Revision: 7, | |
| 91 | + | RawJSONBuf: `{"path":"alpha.go","edits":[{"oldText":"old","newText":"alpha content"}]}`, | |
| 92 | + | LivePath: "alpha.go", | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | ID: "toolu_beta", | |
| 96 | + | Name: "edit", | |
| 97 | + | Revision: 7, | |
| 98 | + | RawJSONBuf: `{"path":"beta.go","edits":[{"oldText":"old","newText":"beta content"}]}`, | |
| 99 | + | LivePath: "beta.go", | |
| 100 | + | }, | |
| 101 | + | }} | |
| 102 | + | ||
| 103 | + | plain := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |
| 104 | + | for _, want := range []string{"alpha.go", "alpha content", "beta.go", "beta content"} { | |
| 105 | + | if !strings.Contains(plain, want) { | |
| 106 | + | t.Fatalf("live tool cache aliased calls at the same revision; missing %q:\n%s", want, plain) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestLiveToolCacheDropsSupersededAndInactiveCalls(t *testing.T) { | |
| 112 | + | v := View{Theme: Dark, ToolCalls: []ToolCallView{{ | |
| 113 | + | ID: "toolu_1", | |
| 114 | + | Name: "edit", | |
| 115 | + | Revision: 1, | |
| 116 | + | RawJSONBuf: `{"path":"sample.go","edits":[{"oldText":"old","newText":"first"}]}`, | |
| 117 | + | LivePath: "sample.go", | |
| 118 | + | }}} | |
| 119 | + | v.BuildLive(80) | |
| 120 | + | ||
| 121 | + | v.ToolCalls[0].Revision = 2 | |
| 122 | + | v.ToolCalls[0].RawJSONBuf = `{"path":"sample.go","edits":[{"oldText":"old","newText":"second"}]}` | |
| 123 | + | v.BuildLive(80) | |
| 124 | + | for key := range v.liveRenderCache { | |
| 125 | + | if key.callID != "toolu_1" || key.revision != 2 { | |
| 126 | + | t.Fatalf("live cache retained superseded entry: %+v", key) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | v.ToolCalls = nil | |
| 131 | + | v.BuildLive(80) | |
| 132 | + | if len(v.liveRenderCache) != 0 { | |
| 133 | + | t.Fatalf("live cache retained %d entries after all calls disappeared", len(v.liveRenderCache)) | |
| 134 | + | } | |
| 135 | + | if len(v.liveBodyHigh) != 0 { | |
| 136 | + | t.Fatalf("live height reservations retained %d inactive calls", len(v.liveBodyHigh)) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 85 | 140 | func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | |
| 86 | 141 | v := View{Theme: Dark} | |
| 87 | 142 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | |
| @@ -105,6 +160,25 @@ func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | |||
| 105 | 160 | } | |
| 106 | 161 | } | |
| 107 | 162 | ||
| 163 | + | func TestLiveEditOverlayKeepsStableHeightWhileStreaming(t *testing.T) { | |
| 164 | + | v := View{Theme: Dark, ToolCalls: []ToolCallView{{ | |
| 165 | + | ID: "toolu_1", | |
| 166 | + | Name: "edit", | |
| 167 | + | Revision: 1, | |
| 168 | + | RawJSONBuf: `{"path":"sample.go","edits":[{"oldText":"old","newText":"line 1"}]}`, | |
| 169 | + | LivePath: "sample.go", | |
| 170 | + | }}} | |
| 171 | + | shortRows := len(v.BuildLive(80)) | |
| 172 | + | ||
| 173 | + | v.ToolCalls[0].Revision = 2 | |
| 174 | + | v.ToolCalls[0].RawJSONBuf = `{"path":"sample.go","edits":[{"oldText":"old","newText":"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14"}]}` | |
| 175 | + | longRows := len(v.BuildLive(80)) | |
| 176 | + | ||
| 177 | + | if longRows != shortRows { | |
| 178 | + | t.Fatalf("live edit changed height while streaming: short=%d rows, long=%d rows", shortRows, longRows) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 108 | 182 | func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) { | |
| 109 | 183 | args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`) | |
| 110 | 184 | v := View{ | |
| @@ -82,6 +82,61 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | func TestLiveToolCacheSeparatesCallsAtTheSameRevision(t *testing.T) { | |||
| 86 | + | v := View{Theme: Dark, ToolCalls: []ToolCallView{ | |||
| 87 | + | { | |||
| 88 | + | ID: "toolu_alpha", | |||
| 89 | + | Name: "edit", | |||
| 90 | + | Revision: 7, | |||
| 91 | + | RawJSONBuf: `{"path":"alpha.go","edits":[{"oldText":"old","newText":"alpha content"}]}`, | |||
| 92 | + | LivePath: "alpha.go", | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | ID: "toolu_beta", | |||
| 96 | + | Name: "edit", | |||
| 97 | + | Revision: 7, | |||
| 98 | + | RawJSONBuf: `{"path":"beta.go","edits":[{"oldText":"old","newText":"beta content"}]}`, | |||
| 99 | + | LivePath: "beta.go", | |||
| 100 | + | }, | |||
| 101 | + | }} | |||
| 102 | + | ||||
| 103 | + | plain := stripANSI(strings.Join(v.BuildLive(80), "\n")) | |||
| 104 | + | for _, want := range []string{"alpha.go", "alpha content", "beta.go", "beta content"} { | |||
| 105 | + | if !strings.Contains(plain, want) { | |||
| 106 | + | t.Fatalf("live tool cache aliased calls at the same revision; missing %q:\n%s", want, plain) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestLiveToolCacheDropsSupersededAndInactiveCalls(t *testing.T) { | |||
| 112 | + | v := View{Theme: Dark, ToolCalls: []ToolCallView{{ | |||
| 113 | + | ID: "toolu_1", | |||
| 114 | + | Name: "edit", | |||
| 115 | + | Revision: 1, | |||
| 116 | + | RawJSONBuf: `{"path":"sample.go","edits":[{"oldText":"old","newText":"first"}]}`, | |||
| 117 | + | LivePath: "sample.go", | |||
| 118 | + | }}} | |||
| 119 | + | v.BuildLive(80) | |||
| 120 | + | ||||
| 121 | + | v.ToolCalls[0].Revision = 2 | |||
| 122 | + | v.ToolCalls[0].RawJSONBuf = `{"path":"sample.go","edits":[{"oldText":"old","newText":"second"}]}` | |||
| 123 | + | v.BuildLive(80) | |||
| 124 | + | for key := range v.liveRenderCache { | |||
| 125 | + | if key.callID != "toolu_1" || key.revision != 2 { | |||
| 126 | + | t.Fatalf("live cache retained superseded entry: %+v", key) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | v.ToolCalls = nil | |||
| 131 | + | v.BuildLive(80) | |||
| 132 | + | if len(v.liveRenderCache) != 0 { | |||
| 133 | + | t.Fatalf("live cache retained %d entries after all calls disappeared", len(v.liveRenderCache)) | |||
| 134 | + | } | |||
| 135 | + | if len(v.liveBodyHigh) != 0 { | |||
| 136 | + | t.Fatalf("live height reservations retained %d inactive calls", len(v.liveBodyHigh)) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 85 | func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | 140 | func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | ||
| 86 | v := View{Theme: Dark} | 141 | v := View{Theme: Dark} | ||
| 87 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | 142 | tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`) | ||
| @@ -105,6 +160,25 @@ func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) { | |||||
| 105 | } | 160 | } | ||
| 106 | } | 161 | } | ||
| 107 | 162 | ||||
| 163 | + | func TestLiveEditOverlayKeepsStableHeightWhileStreaming(t *testing.T) { | |||
| 164 | + | v := View{Theme: Dark, ToolCalls: []ToolCallView{{ | |||
| 165 | + | ID: "toolu_1", | |||
| 166 | + | Name: "edit", | |||
| 167 | + | Revision: 1, | |||
| 168 | + | RawJSONBuf: `{"path":"sample.go","edits":[{"oldText":"old","newText":"line 1"}]}`, | |||
| 169 | + | LivePath: "sample.go", | |||
| 170 | + | }}} | |||
| 171 | + | shortRows := len(v.BuildLive(80)) | |||
| 172 | + | ||||
| 173 | + | v.ToolCalls[0].Revision = 2 | |||
| 174 | + | v.ToolCalls[0].RawJSONBuf = `{"path":"sample.go","edits":[{"oldText":"old","newText":"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14"}]}` | |||
| 175 | + | longRows := len(v.BuildLive(80)) | |||
| 176 | + | ||||
| 177 | + | if longRows != shortRows { | |||
| 178 | + | t.Fatalf("live edit changed height while streaming: short=%d rows, long=%d rows", shortRows, longRows) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 108 | func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) { | 182 | func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) { | ||
| 109 | args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`) | 183 | args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`) | ||
| 110 | v := View{ | 184 | v := View{ | ||