interactive: cache idle chat render
The redraw path rebuilt the full transcript on every key event: filtered the agent's full message slice, refreshed tool path maps, walked every message through the per-message render cache, and re-assembled the entire chat line buffer. With a long session, the O(N) work per keystroke made typing visibly lag. Add an idle render cache: the previously built chat is reused when nothing relevant changed (terminal width, transcript revision, status notes, help/update banners, expand-all). The agent now exposes a cheap monotonically increasing Revision() that ticks whenever messages are appended or replaced, so the cache key stays trivial. Live turns (busy/streaming/tool-call mutations) keep the old rebuild path.
- commit
- b45f10718f6993cb18eea301e3a0a17351a572f7
- author
- patriceckhart · · 2026-05-04 10:14 UTC
- parent
- 2eab133
- signature
- unsigned
2 files · +147 -56
M internal/agent/modes/interactive.go +127 -55
| @@ -153,6 +153,21 @@ type ChangelogPayload struct { | |||
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | 155 | // Interactive is the TUI chat loop. | |
| 156 | + | type chatCacheKey struct { | |
| 157 | + | cols int | |
| 158 | + | agentRev uint64 | |
| 159 | + | statusOK string | |
| 160 | + | statusErr string | |
| 161 | + | help string | |
| 162 | + | extNotes string | |
| 163 | + | updateAvailable bool | |
| 164 | + | updateCurrent string | |
| 165 | + | updateLatest string | |
| 166 | + | updateURL string | |
| 167 | + | welcomeShowVer bool | |
| 168 | + | expandAll bool | |
| 169 | + | } | |
| 170 | + | ||
| 156 | 171 | type Interactive struct { | |
| 157 | 172 | cfg InteractiveConfig | |
| 158 | 173 | view *tui.View | |
| @@ -203,6 +218,14 @@ type Interactive struct { | |||
| 203 | 218 | prevChatLen int | |
| 204 | 219 | prevChatCols int | |
| 205 | 220 | ||
| 221 | + | // chatCache stores the built transcript/status-note rows for idle | |
| 222 | + | // frames. Editor typing changes only the bottom input region, so | |
| 223 | + | // reusing this cache avoids copying/walking/reassembling a long | |
| 224 | + | // session on every keypress. | |
| 225 | + | chatCache []string | |
| 226 | + | chatCacheKey chatCacheKey | |
| 227 | + | chatCacheValid bool | |
| 228 | + | ||
| 206 | 229 | // Messages typed while a turn is in flight. Each is delivered as | |
| 207 | 230 | // its own follow-up turn once the current one finishes. Rendered | |
| 208 | 231 | // above the status bar as "sliding in: ..." chips. | |
| @@ -573,66 +596,51 @@ func mouseWheelScrollRows() int { | |||
| 573 | 596 | return 6 | |
| 574 | 597 | } | |
| 575 | 598 | ||
| 576 | - | // lastCols returns the current terminal width in columns. | |
| 577 | - | func (i *Interactive) lastCols() int { | |
| 578 | - | cols, _ := i.cfg.Terminal.Size() | |
| 579 | - | return cols | |
| 580 | - | } | |
| 581 | - | ||
| 582 | - | // chatPage returns the number of chat rows currently visible, used | |
| 583 | - | // as the page size for PageUp/PageDown. | |
| 584 | - | func (i *Interactive) chatPage() int { | |
| 585 | - | _, rows := i.cfg.Terminal.Size() | |
| 586 | - | p := rows - 6 // rough reservation for status + editor + a dialog line | |
| 587 | - | if p < 4 { | |
| 588 | - | p = 4 | |
| 599 | + | func (i *Interactive) cachedChatLocked(cols int) []string { | |
| 600 | + | key, cacheable := i.chatCacheKeyLocked(cols) | |
| 601 | + | if cacheable && i.chatCacheValid && i.chatCacheKey == key { | |
| 602 | + | return append([]string(nil), i.chatCache...) | |
| 589 | 603 | } | |
| 590 | - | return p | |
| 591 | - | } | |
| 592 | - | ||
| 593 | - | // scrollBy adjusts the scroll offset. Positive = up (into history). | |
| 594 | - | // Clearing the parked-turn label when we're back at the bottom means | |
| 595 | - | // the "viewing turn N" footer goes away automatically as soon as you | |
| 596 | - | // scroll back to the live tail. | |
| 597 | - | func (i *Interactive) scrollBy(delta int) { | |
| 598 | - | i.mu.Lock() | |
| 599 | - | i.scrollOffset += delta | |
| 600 | - | if i.scrollOffset < 0 { | |
| 601 | - | i.scrollOffset = 0 | |
| 602 | - | } | |
| 603 | - | if i.scrollOffset == 0 { | |
| 604 | - | i.parkedTurn = 0 | |
| 605 | - | i.parkedTotal = 0 | |
| 606 | - | } | |
| 607 | - | if i.rend != nil { | |
| 608 | - | // VS Code's terminal is especially prone to leaving stray | |
| 609 | - | // wrapped-character fragments behind during scroll-driven | |
| 610 | - | // viewport changes. Force a full repaint on scroll, but | |
| 611 | - | // avoid a whole-screen clear because that visibly flickers. | |
| 612 | - | i.rend.Invalidate() | |
| 604 | + | chat := i.buildChatLocked(cols) | |
| 605 | + | if cacheable { | |
| 606 | + | i.chatCache = append(i.chatCache[:0], chat...) | |
| 607 | + | i.chatCacheKey = key | |
| 608 | + | i.chatCacheValid = true | |
| 609 | + | } else { | |
| 610 | + | i.chatCacheValid = false | |
| 613 | 611 | } | |
| 614 | - | i.mu.Unlock() | |
| 615 | - | i.invalidate() | |
| 612 | + | return chat | |
| 616 | 613 | } | |
| 617 | 614 | ||
| 618 | - | // scrollToBottom pins the view to the latest content. | |
| 619 | - | func (i *Interactive) scrollToBottom() { | |
| 620 | - | i.mu.Lock() | |
| 621 | - | i.scrollOffset = 0 | |
| 622 | - | i.parkedTurn = 0 | |
| 623 | - | i.parkedTotal = 0 | |
| 624 | - | if i.rend != nil { | |
| 625 | - | i.rend.Invalidate() | |
| 615 | + | func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |
| 616 | + | // Live turns mutate streaming/tool-call state at high frequency; | |
| 617 | + | // keep those on the old rebuild path. The cache targets the common | |
| 618 | + | // idle case where only the editor contents changed between redraws. | |
| 619 | + | if i.busy || i.streamOn || i.streamFlushPending { | |
| 620 | + | return chatCacheKey{}, false | |
| 626 | 621 | } | |
| 627 | - | i.mu.Unlock() | |
| 628 | - | i.invalidate() | |
| 629 | - | } | |
| 630 | - | ||
| 631 | - | func (i *Interactive) redraw() { | |
| 632 | - | i.mu.Lock() | |
| 633 | - | defer i.mu.Unlock() | |
| 634 | - | ||
| 635 | - | cols, _ := i.cfg.Terminal.Size() | |
| 622 | + | var rev uint64 | |
| 623 | + | if i.agent != nil { | |
| 624 | + | rev = i.agent.Revision() | |
| 625 | + | } | |
| 626 | + | showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 627 | + | return chatCacheKey{ | |
| 628 | + | cols: cols, | |
| 629 | + | agentRev: rev, | |
| 630 | + | statusOK: i.statusOK, | |
| 631 | + | statusErr: i.statusErr, | |
| 632 | + | help: strings.Join(i.helpBlock, "\n"), | |
| 633 | + | extNotes: strings.Join(i.extNotes, "\n"), | |
| 634 | + | updateAvailable: i.updateInfo.Available, | |
| 635 | + | updateCurrent: i.updateInfo.Current, | |
| 636 | + | updateLatest: i.updateInfo.Latest, | |
| 637 | + | updateURL: i.updateInfo.URL, | |
| 638 | + | welcomeShowVer: showVer, | |
| 639 | + | expandAll: i.view.ExpandAll, | |
| 640 | + | }, true | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func (i *Interactive) buildChatLocked(cols int) []string { | |
| 636 | 644 | if i.agent != nil { | |
| 637 | 645 | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 638 | 646 | } else { | |
| @@ -744,6 +752,70 @@ func (i *Interactive) redraw() { | |||
| 744 | 752 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | |
| 745 | 753 | chat = chat[:len(chat)-1] | |
| 746 | 754 | } | |
| 755 | + | return chat | |
| 756 | + | } | |
| 757 | + | ||
| 758 | + | // lastCols returns the current terminal width in columns. | |
| 759 | + | func (i *Interactive) lastCols() int { | |
| 760 | + | cols, _ := i.cfg.Terminal.Size() | |
| 761 | + | return cols | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | // chatPage returns the number of chat rows currently visible, used | |
| 765 | + | // as the page size for PageUp/PageDown. | |
| 766 | + | func (i *Interactive) chatPage() int { | |
| 767 | + | _, rows := i.cfg.Terminal.Size() | |
| 768 | + | p := rows - 6 // rough reservation for status + editor + a dialog line | |
| 769 | + | if p < 4 { | |
| 770 | + | p = 4 | |
| 771 | + | } | |
| 772 | + | return p | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // scrollBy adjusts the scroll offset. Positive = up (into history). | |
| 776 | + | // Clearing the parked-turn label when we're back at the bottom means | |
| 777 | + | // the "viewing turn N" footer goes away automatically as soon as you | |
| 778 | + | // scroll back to the live tail. | |
| 779 | + | func (i *Interactive) scrollBy(delta int) { | |
| 780 | + | i.mu.Lock() | |
| 781 | + | i.scrollOffset += delta | |
| 782 | + | if i.scrollOffset < 0 { | |
| 783 | + | i.scrollOffset = 0 | |
| 784 | + | } | |
| 785 | + | if i.scrollOffset == 0 { | |
| 786 | + | i.parkedTurn = 0 | |
| 787 | + | i.parkedTotal = 0 | |
| 788 | + | } | |
| 789 | + | if i.rend != nil { | |
| 790 | + | // VS Code's terminal is especially prone to leaving stray | |
| 791 | + | // wrapped-character fragments behind during scroll-driven | |
| 792 | + | // viewport changes. Force a full repaint on scroll, but | |
| 793 | + | // avoid a whole-screen clear because that visibly flickers. | |
| 794 | + | i.rend.Invalidate() | |
| 795 | + | } | |
| 796 | + | i.mu.Unlock() | |
| 797 | + | i.invalidate() | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | // scrollToBottom pins the view to the latest content. | |
| 801 | + | func (i *Interactive) scrollToBottom() { | |
| 802 | + | i.mu.Lock() | |
| 803 | + | i.scrollOffset = 0 | |
| 804 | + | i.parkedTurn = 0 | |
| 805 | + | i.parkedTotal = 0 | |
| 806 | + | if i.rend != nil { | |
| 807 | + | i.rend.Invalidate() | |
| 808 | + | } | |
| 809 | + | i.mu.Unlock() | |
| 810 | + | i.invalidate() | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | func (i *Interactive) redraw() { | |
| 814 | + | i.mu.Lock() | |
| 815 | + | defer i.mu.Unlock() | |
| 816 | + | ||
| 817 | + | cols, _ := i.cfg.Terminal.Size() | |
| 818 | + | chat := i.cachedChatLocked(cols) | |
| 747 | 819 | ||
| 748 | 820 | // Dialogs (login or model picker) render between chat and the editor. | |
| 749 | 821 | var dialog []string | |
| @@ -153,6 +153,21 @@ type ChangelogPayload struct { | |||||
| 153 | } | 153 | } | ||
| 154 | 154 | ||||
| 155 | // Interactive is the TUI chat loop. | 155 | // Interactive is the TUI chat loop. | ||
| 156 | + | type chatCacheKey struct { | |||
| 157 | + | cols int | |||
| 158 | + | agentRev uint64 | |||
| 159 | + | statusOK string | |||
| 160 | + | statusErr string | |||
| 161 | + | help string | |||
| 162 | + | extNotes string | |||
| 163 | + | updateAvailable bool | |||
| 164 | + | updateCurrent string | |||
| 165 | + | updateLatest string | |||
| 166 | + | updateURL string | |||
| 167 | + | welcomeShowVer bool | |||
| 168 | + | expandAll bool | |||
| 169 | + | } | |||
| 170 | + | ||||
| 156 | type Interactive struct { | 171 | type Interactive struct { | ||
| 157 | cfg InteractiveConfig | 172 | cfg InteractiveConfig | ||
| 158 | view *tui.View | 173 | view *tui.View | ||
| @@ -203,6 +218,14 @@ type Interactive struct { | |||||
| 203 | prevChatLen int | 218 | prevChatLen int | ||
| 204 | prevChatCols int | 219 | prevChatCols int | ||
| 205 | 220 | ||||
| 221 | + | // chatCache stores the built transcript/status-note rows for idle | |||
| 222 | + | // frames. Editor typing changes only the bottom input region, so | |||
| 223 | + | // reusing this cache avoids copying/walking/reassembling a long | |||
| 224 | + | // session on every keypress. | |||
| 225 | + | chatCache []string | |||
| 226 | + | chatCacheKey chatCacheKey | |||
| 227 | + | chatCacheValid bool | |||
| 228 | + | ||||
| 206 | // Messages typed while a turn is in flight. Each is delivered as | 229 | // Messages typed while a turn is in flight. Each is delivered as | ||
| 207 | // its own follow-up turn once the current one finishes. Rendered | 230 | // its own follow-up turn once the current one finishes. Rendered | ||
| 208 | // above the status bar as "sliding in: ..." chips. | 231 | // above the status bar as "sliding in: ..." chips. | ||
| @@ -573,66 +596,51 @@ func mouseWheelScrollRows() int { | |||||
| 573 | return 6 | 596 | return 6 | ||
| 574 | } | 597 | } | ||
| 575 | 598 | ||||
| 576 | - | // lastCols returns the current terminal width in columns. | 599 | + | func (i *Interactive) cachedChatLocked(cols int) []string { |
| 577 | - | func (i *Interactive) lastCols() int { | 600 | + | key, cacheable := i.chatCacheKeyLocked(cols) |
| 578 | - | cols, _ := i.cfg.Terminal.Size() | 601 | + | if cacheable && i.chatCacheValid && i.chatCacheKey == key { |
| 579 | - | return cols | 602 | + | return append([]string(nil), i.chatCache...) |
| 580 | - | } | |||
| 581 | - | ||||
| 582 | - | // chatPage returns the number of chat rows currently visible, used | |||
| 583 | - | // as the page size for PageUp/PageDown. | |||
| 584 | - | func (i *Interactive) chatPage() int { | |||
| 585 | - | _, rows := i.cfg.Terminal.Size() | |||
| 586 | - | p := rows - 6 // rough reservation for status + editor + a dialog line | |||
| 587 | - | if p < 4 { | |||
| 588 | - | p = 4 | |||
| 589 | } | 603 | } | ||
| 590 | - | return p | 604 | + | chat := i.buildChatLocked(cols) |
| 591 | - | } | 605 | + | if cacheable { |
| 592 | - | 606 | + | i.chatCache = append(i.chatCache[:0], chat...) | |
| 593 | - | // scrollBy adjusts the scroll offset. Positive = up (into history). | 607 | + | i.chatCacheKey = key |
| 594 | - | // Clearing the parked-turn label when we're back at the bottom means | 608 | + | i.chatCacheValid = true |
| 595 | - | // the "viewing turn N" footer goes away automatically as soon as you | 609 | + | } else { |
| 596 | - | // scroll back to the live tail. | 610 | + | i.chatCacheValid = false |
| 597 | - | func (i *Interactive) scrollBy(delta int) { | |||
| 598 | - | i.mu.Lock() | |||
| 599 | - | i.scrollOffset += delta | |||
| 600 | - | if i.scrollOffset < 0 { | |||
| 601 | - | i.scrollOffset = 0 | |||
| 602 | - | } | |||
| 603 | - | if i.scrollOffset == 0 { | |||
| 604 | - | i.parkedTurn = 0 | |||
| 605 | - | i.parkedTotal = 0 | |||
| 606 | - | } | |||
| 607 | - | if i.rend != nil { | |||
| 608 | - | // VS Code's terminal is especially prone to leaving stray | |||
| 609 | - | // wrapped-character fragments behind during scroll-driven | |||
| 610 | - | // viewport changes. Force a full repaint on scroll, but | |||
| 611 | - | // avoid a whole-screen clear because that visibly flickers. | |||
| 612 | - | i.rend.Invalidate() | |||
| 613 | } | 611 | } | ||
| 614 | - | i.mu.Unlock() | 612 | + | return chat |
| 615 | - | i.invalidate() | |||
| 616 | } | 613 | } | ||
| 617 | 614 | ||||
| 618 | - | // scrollToBottom pins the view to the latest content. | 615 | + | func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { |
| 619 | - | func (i *Interactive) scrollToBottom() { | 616 | + | // Live turns mutate streaming/tool-call state at high frequency; |
| 620 | - | i.mu.Lock() | 617 | + | // keep those on the old rebuild path. The cache targets the common |
| 621 | - | i.scrollOffset = 0 | 618 | + | // idle case where only the editor contents changed between redraws. |
| 622 | - | i.parkedTurn = 0 | 619 | + | if i.busy || i.streamOn || i.streamFlushPending { |
| 623 | - | i.parkedTotal = 0 | 620 | + | return chatCacheKey{}, false |
| 624 | - | if i.rend != nil { | |||
| 625 | - | i.rend.Invalidate() | |||
| 626 | } | 621 | } | ||
| 627 | - | i.mu.Unlock() | 622 | + | var rev uint64 |
| 628 | - | i.invalidate() | 623 | + | if i.agent != nil { |
| 629 | - | } | 624 | + | rev = i.agent.Revision() |
| 630 | - | 625 | + | } | |
| 631 | - | func (i *Interactive) redraw() { | 626 | + | showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration |
| 632 | - | i.mu.Lock() | 627 | + | return chatCacheKey{ |
| 633 | - | defer i.mu.Unlock() | 628 | + | cols: cols, |
| 634 | - | 629 | + | agentRev: rev, | |
| 635 | - | cols, _ := i.cfg.Terminal.Size() | 630 | + | statusOK: i.statusOK, |
| 631 | + | statusErr: i.statusErr, | |||
| 632 | + | help: strings.Join(i.helpBlock, "\n"), | |||
| 633 | + | extNotes: strings.Join(i.extNotes, "\n"), | |||
| 634 | + | updateAvailable: i.updateInfo.Available, | |||
| 635 | + | updateCurrent: i.updateInfo.Current, | |||
| 636 | + | updateLatest: i.updateInfo.Latest, | |||
| 637 | + | updateURL: i.updateInfo.URL, | |||
| 638 | + | welcomeShowVer: showVer, | |||
| 639 | + | expandAll: i.view.ExpandAll, | |||
| 640 | + | }, true | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 636 | if i.agent != nil { | 644 | if i.agent != nil { | ||
| 637 | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | 645 | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | ||
| 638 | } else { | 646 | } else { | ||
| @@ -744,6 +752,70 @@ func (i *Interactive) redraw() { | |||||
| 744 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | 752 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | ||
| 745 | chat = chat[:len(chat)-1] | 753 | chat = chat[:len(chat)-1] | ||
| 746 | } | 754 | } | ||
| 755 | + | return chat | |||
| 756 | + | } | |||
| 757 | + | ||||
| 758 | + | // lastCols returns the current terminal width in columns. | |||
| 759 | + | func (i *Interactive) lastCols() int { | |||
| 760 | + | cols, _ := i.cfg.Terminal.Size() | |||
| 761 | + | return cols | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | // chatPage returns the number of chat rows currently visible, used | |||
| 765 | + | // as the page size for PageUp/PageDown. | |||
| 766 | + | func (i *Interactive) chatPage() int { | |||
| 767 | + | _, rows := i.cfg.Terminal.Size() | |||
| 768 | + | p := rows - 6 // rough reservation for status + editor + a dialog line | |||
| 769 | + | if p < 4 { | |||
| 770 | + | p = 4 | |||
| 771 | + | } | |||
| 772 | + | return p | |||
| 773 | + | } | |||
| 774 | + | ||||
| 775 | + | // scrollBy adjusts the scroll offset. Positive = up (into history). | |||
| 776 | + | // Clearing the parked-turn label when we're back at the bottom means | |||
| 777 | + | // the "viewing turn N" footer goes away automatically as soon as you | |||
| 778 | + | // scroll back to the live tail. | |||
| 779 | + | func (i *Interactive) scrollBy(delta int) { | |||
| 780 | + | i.mu.Lock() | |||
| 781 | + | i.scrollOffset += delta | |||
| 782 | + | if i.scrollOffset < 0 { | |||
| 783 | + | i.scrollOffset = 0 | |||
| 784 | + | } | |||
| 785 | + | if i.scrollOffset == 0 { | |||
| 786 | + | i.parkedTurn = 0 | |||
| 787 | + | i.parkedTotal = 0 | |||
| 788 | + | } | |||
| 789 | + | if i.rend != nil { | |||
| 790 | + | // VS Code's terminal is especially prone to leaving stray | |||
| 791 | + | // wrapped-character fragments behind during scroll-driven | |||
| 792 | + | // viewport changes. Force a full repaint on scroll, but | |||
| 793 | + | // avoid a whole-screen clear because that visibly flickers. | |||
| 794 | + | i.rend.Invalidate() | |||
| 795 | + | } | |||
| 796 | + | i.mu.Unlock() | |||
| 797 | + | i.invalidate() | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | // scrollToBottom pins the view to the latest content. | |||
| 801 | + | func (i *Interactive) scrollToBottom() { | |||
| 802 | + | i.mu.Lock() | |||
| 803 | + | i.scrollOffset = 0 | |||
| 804 | + | i.parkedTurn = 0 | |||
| 805 | + | i.parkedTotal = 0 | |||
| 806 | + | if i.rend != nil { | |||
| 807 | + | i.rend.Invalidate() | |||
| 808 | + | } | |||
| 809 | + | i.mu.Unlock() | |||
| 810 | + | i.invalidate() | |||
| 811 | + | } | |||
| 812 | + | ||||
| 813 | + | func (i *Interactive) redraw() { | |||
| 814 | + | i.mu.Lock() | |||
| 815 | + | defer i.mu.Unlock() | |||
| 816 | + | ||||
| 817 | + | cols, _ := i.cfg.Terminal.Size() | |||
| 818 | + | chat := i.cachedChatLocked(cols) | |||
| 747 | 819 | ||||
| 748 | // Dialogs (login or model picker) render between chat and the editor. | 820 | // Dialogs (login or model picker) render between chat and the editor. | ||
| 749 | var dialog []string | 821 | var dialog []string | ||
M internal/core/agent.go +20 -1
| @@ -68,7 +68,12 @@ type Agent struct { | |||
| 68 | 68 | ||
| 69 | 69 | mu sync.Mutex | |
| 70 | 70 | messages []provider.Message | |
| 71 | - | cost CostTracker | |
| 71 | + | // rev increments whenever the transcript slice is replaced or a | |
| 72 | + | // message is appended. The TUI uses it as a cheap redraw cache key | |
| 73 | + | // so editor-only typing doesn't copy/rebuild a long transcript on | |
| 74 | + | // every keypress. | |
| 75 | + | rev uint64 | |
| 76 | + | cost CostTracker | |
| 72 | 77 | } | |
| 73 | 78 | ||
| 74 | 79 | // NewAgent returns an Agent with sensible defaults. | |
| @@ -91,6 +96,15 @@ func (a *Agent) Messages() []provider.Message { | |||
| 91 | 96 | return out | |
| 92 | 97 | } | |
| 93 | 98 | ||
| 99 | + | // Revision returns a monotonically increasing transcript version. | |
| 100 | + | // It is cheap to query and changes whenever Messages() would return | |
| 101 | + | // different transcript content because of append/set operations. | |
| 102 | + | func (a *Agent) Revision() uint64 { | |
| 103 | + | a.mu.Lock() | |
| 104 | + | defer a.mu.Unlock() | |
| 105 | + | return a.rev | |
| 106 | + | } | |
| 107 | + | ||
| 94 | 108 | // SetTools swaps the tool registry. Used by /reload-ext to hand | |
| 95 | 109 | // the agent a fresh registry after extension subprocesses have been | |
| 96 | 110 | // respawned (and their freshly-registered tools merged in). | |
| @@ -105,6 +119,7 @@ func (a *Agent) SetMessages(msgs []provider.Message) { | |||
| 105 | 119 | a.mu.Lock() | |
| 106 | 120 | defer a.mu.Unlock() | |
| 107 | 121 | a.messages = append(a.messages[:0], msgs...) | |
| 122 | + | a.rev++ | |
| 108 | 123 | } | |
| 109 | 124 | ||
| 110 | 125 | // Cost returns the cumulative usage. | |
| @@ -152,6 +167,7 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image | |||
| 152 | 167 | ||
| 153 | 168 | a.mu.Lock() | |
| 154 | 169 | a.messages = append(a.messages, user) | |
| 170 | + | a.rev++ | |
| 155 | 171 | a.mu.Unlock() | |
| 156 | 172 | a.fireMessageAppended(user) | |
| 157 | 173 | sink(EvUserMessage{Message: user}) | |
| @@ -207,6 +223,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||
| 207 | 223 | toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink) | |
| 208 | 224 | a.mu.Lock() | |
| 209 | 225 | a.messages = append(a.messages, toolMsg) | |
| 226 | + | a.rev++ | |
| 210 | 227 | // OpenAI's chat-completions tool message shape is text-centric. | |
| 211 | 228 | // Vision models reliably consume images when they arrive as user | |
| 212 | 229 | // content, so when a tool result contains images we mirror them | |
| @@ -218,6 +235,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||
| 218 | 235 | if a.Client != nil && a.Client.Name() == "openai" { | |
| 219 | 236 | if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 { | |
| 220 | 237 | a.messages = append(a.messages, mirror) | |
| 238 | + | a.rev++ | |
| 221 | 239 | imageMirror = mirror | |
| 222 | 240 | } | |
| 223 | 241 | } | |
| @@ -336,6 +354,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St | |||
| 336 | 354 | ||
| 337 | 355 | a.mu.Lock() | |
| 338 | 356 | a.messages = append(a.messages, finalMsg) | |
| 357 | + | a.rev++ | |
| 339 | 358 | a.mu.Unlock() | |
| 340 | 359 | a.fireMessageAppended(finalMsg) | |
| 341 | 360 | if !suppress { | |
| @@ -68,7 +68,12 @@ type Agent struct { | |||||
| 68 | 68 | ||||
| 69 | mu sync.Mutex | 69 | mu sync.Mutex | ||
| 70 | messages []provider.Message | 70 | messages []provider.Message | ||
| 71 | - | cost CostTracker | 71 | + | // rev increments whenever the transcript slice is replaced or a |
| 72 | + | // message is appended. The TUI uses it as a cheap redraw cache key | |||
| 73 | + | // so editor-only typing doesn't copy/rebuild a long transcript on | |||
| 74 | + | // every keypress. | |||
| 75 | + | rev uint64 | |||
| 76 | + | cost CostTracker | |||
| 72 | } | 77 | } | ||
| 73 | 78 | ||||
| 74 | // NewAgent returns an Agent with sensible defaults. | 79 | // NewAgent returns an Agent with sensible defaults. | ||
| @@ -91,6 +96,15 @@ func (a *Agent) Messages() []provider.Message { | |||||
| 91 | return out | 96 | return out | ||
| 92 | } | 97 | } | ||
| 93 | 98 | ||||
| 99 | + | // Revision returns a monotonically increasing transcript version. | |||
| 100 | + | // It is cheap to query and changes whenever Messages() would return | |||
| 101 | + | // different transcript content because of append/set operations. | |||
| 102 | + | func (a *Agent) Revision() uint64 { | |||
| 103 | + | a.mu.Lock() | |||
| 104 | + | defer a.mu.Unlock() | |||
| 105 | + | return a.rev | |||
| 106 | + | } | |||
| 107 | + | ||||
| 94 | // SetTools swaps the tool registry. Used by /reload-ext to hand | 108 | // SetTools swaps the tool registry. Used by /reload-ext to hand | ||
| 95 | // the agent a fresh registry after extension subprocesses have been | 109 | // the agent a fresh registry after extension subprocesses have been | ||
| 96 | // respawned (and their freshly-registered tools merged in). | 110 | // respawned (and their freshly-registered tools merged in). | ||
| @@ -105,6 +119,7 @@ func (a *Agent) SetMessages(msgs []provider.Message) { | |||||
| 105 | a.mu.Lock() | 119 | a.mu.Lock() | ||
| 106 | defer a.mu.Unlock() | 120 | defer a.mu.Unlock() | ||
| 107 | a.messages = append(a.messages[:0], msgs...) | 121 | a.messages = append(a.messages[:0], msgs...) | ||
| 122 | + | a.rev++ | |||
| 108 | } | 123 | } | ||
| 109 | 124 | ||||
| 110 | // Cost returns the cumulative usage. | 125 | // Cost returns the cumulative usage. | ||
| @@ -152,6 +167,7 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image | |||||
| 152 | 167 | ||||
| 153 | a.mu.Lock() | 168 | a.mu.Lock() | ||
| 154 | a.messages = append(a.messages, user) | 169 | a.messages = append(a.messages, user) | ||
| 170 | + | a.rev++ | |||
| 155 | a.mu.Unlock() | 171 | a.mu.Unlock() | ||
| 156 | a.fireMessageAppended(user) | 172 | a.fireMessageAppended(user) | ||
| 157 | sink(EvUserMessage{Message: user}) | 173 | sink(EvUserMessage{Message: user}) | ||
| @@ -207,6 +223,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||||
| 207 | toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink) | 223 | toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink) | ||
| 208 | a.mu.Lock() | 224 | a.mu.Lock() | ||
| 209 | a.messages = append(a.messages, toolMsg) | 225 | a.messages = append(a.messages, toolMsg) | ||
| 226 | + | a.rev++ | |||
| 210 | // OpenAI's chat-completions tool message shape is text-centric. | 227 | // OpenAI's chat-completions tool message shape is text-centric. | ||
| 211 | // Vision models reliably consume images when they arrive as user | 228 | // Vision models reliably consume images when they arrive as user | ||
| 212 | // content, so when a tool result contains images we mirror them | 229 | // content, so when a tool result contains images we mirror them | ||
| @@ -218,6 +235,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||||
| 218 | if a.Client != nil && a.Client.Name() == "openai" { | 235 | if a.Client != nil && a.Client.Name() == "openai" { | ||
| 219 | if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 { | 236 | if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 { | ||
| 220 | a.messages = append(a.messages, mirror) | 237 | a.messages = append(a.messages, mirror) | ||
| 238 | + | a.rev++ | |||
| 221 | imageMirror = mirror | 239 | imageMirror = mirror | ||
| 222 | } | 240 | } | ||
| 223 | } | 241 | } | ||
| @@ -336,6 +354,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St | |||||
| 336 | 354 | ||||
| 337 | a.mu.Lock() | 355 | a.mu.Lock() | ||
| 338 | a.messages = append(a.messages, finalMsg) | 356 | a.messages = append(a.messages, finalMsg) | ||
| 357 | + | a.rev++ | |||
| 339 | a.mu.Unlock() | 358 | a.mu.Unlock() | ||
| 340 | a.fireMessageAppended(finalMsg) | 359 | a.fireMessageAppended(finalMsg) | ||
| 341 | if !suppress { | 360 | if !suppress { | ||