detached 1c579d8

fix(tui): isolate live tool render frames

commit
1c579d8ea14a9396f5964bf6709db2c3de9e97d9
author
brice · · 2026-08-10 05:44 UTC
parent
befd698
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +163 -34
M packages/agent/modes/interactive.go +24 -13
@@ -654,6 +654,10 @@ type Interactive struct {
654654 streamFlushPending bool
655655 toolCalls map[string]*tui.ToolCallView
656656 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
657661 // toolGate records, per tool-call id, how many runes of paced
658662 // assistant text must have drained into `streaming` before that
659663 // tool block may appear. It exists to make stream ordering
@@ -8164,12 +8168,17 @@ func eventAffectsPresentation(ev core.AgentEvent) bool {
81648168 }
81658169 }
81668170
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
81718180 }
8172- tc.Revision++
8181+ tc.Revision = i.toolRenderRevision
81738182 }
81748183
81758184 func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
@@ -8234,19 +8243,20 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
82348243 // EvToolCall for the same ID updates the same struct (the
82358244 // final parsed args + name are already known here).
82368245 if _, exists := i.toolCalls[e.ID]; !exists {
8237- i.toolCalls[e.ID] = &tui.ToolCallView{
8246+ tc := &tui.ToolCallView{
82388247 ID: e.ID,
82398248 Name: e.Name,
8240- Revision: 1,
82418249 Streaming: true,
82428250 }
8251+ i.bumpToolRevisionLocked(tc)
8252+ i.toolCalls[e.ID] = tc
82438253 i.toolOrder = append(i.toolOrder, e.ID)
82448254 i.gateToolLocked(e.ID)
82458255 }
82468256 case core.EvToolUseArgs:
82478257 if tc, ok := i.toolCalls[e.ID]; ok {
82488258 tc.RawJSONBuf += e.Delta
8249- bumpToolRevision(tc)
8259+ i.bumpToolRevisionLocked(tc)
82508260 // Refresh the live path as soon as it parses; used in
82518261 // the header (write /Users/example/Desktop/demo.ts)
82528262 // while the content is still streaming.
@@ -8259,7 +8269,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
82598269 case core.EvToolUseEnd:
82608270 if tc, ok := i.toolCalls[e.ID]; ok {
82618271 tc.Streaming = false
8262- bumpToolRevision(tc)
8272+ i.bumpToolRevisionLocked(tc)
82638273 }
82648274 case core.EvToolCall:
82658275 // If we already pre-created the view during streaming, just
@@ -8267,19 +8277,20 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
82678277 // (non-streaming providers or legacy paths).
82688278 if tc, ok := i.toolCalls[e.ID]; ok {
82698279 tc.Args = tui.ShortArgs(e.Name, e.Args)
8270- bumpToolRevision(tc)
8280+ i.bumpToolRevisionLocked(tc)
82718281 if tc.RawJSONBuf == "" {
82728282 tc.RawJSONBuf = string(e.Args)
82738283 }
82748284 tc.Streaming = false
82758285 } else {
8276- i.toolCalls[e.ID] = &tui.ToolCallView{
8286+ tc := &tui.ToolCallView{
82778287 ID: e.ID,
82788288 Name: e.Name,
8279- Revision: 1,
82808289 Args: tui.ShortArgs(e.Name, e.Args),
82818290 RawJSONBuf: string(e.Args),
82828291 }
8292+ i.bumpToolRevisionLocked(tc)
8293+ i.toolCalls[e.ID] = tc
82838294 i.toolOrder = append(i.toolOrder, e.ID)
82848295 i.gateToolLocked(e.ID)
82858296 }
@@ -8298,7 +8309,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
82988309 }
82998310 }
83008311 tc.Result = text.String()
8301- bumpToolRevision(tc)
8312+ i.bumpToolRevisionLocked(tc)
83028313 }
83038314 if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive {
83048315 i.goalStatus = update.Status
@@ -654,6 +654,10 @@ type Interactive struct {
654 streamFlushPending bool654 streamFlushPending bool
655 toolCalls map[string]*tui.ToolCallView655 toolCalls map[string]*tui.ToolCallView
656 toolOrder []string656 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 paced661 // toolGate records, per tool-call id, how many runes of paced
658 // assistant text must have drained into `streaming` before that662 // assistant text must have drained into `streaming` before that
659 // tool block may appear. It exists to make stream ordering663 // 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 = 18173+ if i.toolRenderRevision == 0 {
8170- return8174+ // 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 (the8243 // 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.Delta8258 tc.RawJSONBuf += e.Delta
8249- bumpToolRevision(tc)8259+ i.bumpToolRevisionLocked(tc)
8250 // Refresh the live path as soon as it parses; used in8260 // 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 = false8271 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, just8275 // 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 = false8284 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.Status8315 i.goalStatus = update.Status
M packages/agent/modes/interactive_render_test.go +22 -0
@@ -65,6 +65,28 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
6565 }
6666 }
6767
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+
6890 func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
6991 scheduler := newLatestFrameScheduler()
7092 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 {
234234 }
235235
236236 type liveToolCacheKey struct {
237+ callID string
237238 revision uint64
238239 content uint64
239240 width int
@@ -390,7 +391,7 @@ func (v *View) Build(width int) []string {
390391 // scrollback renderers can keep these rows outside the immutable transcript
391392 // so native scrolling stays stable while a turn streams.
392393 func (v *View) BuildLive(width int) []string {
393- v.prepareLiveBodyHeights()
394+ v.prepareLiveToolState()
394395 if v.liveRenderCache == nil {
395396 v.liveRenderCache = make(map[liveToolCacheKey][]string)
396397 }
@@ -531,22 +532,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
531532 if v.liveRenderCache == nil {
532533 v.liveRenderCache = make(map[liveToolCacheKey][]string)
533534 }
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()
550536
551537 // Pre-render every message (hits the cache for unchanged ones) so
552538 // we can allocate `out` in a single shot with the exact capacity.
@@ -665,14 +651,42 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
665651 return out, anchors
666652 }
667653
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() {
672658 if v.liveBodyHigh == nil || v.liveBodyExpandAll != v.ExpandAll {
673659 v.liveBodyHigh = make(map[string]int)
674660 v.liveBodyExpandAll = v.ExpandAll
675661 }
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+ }
676690 }
677691
678692 // refreshToolPaths rebuilds the tool_use_id -> path map from the
@@ -1102,6 +1116,7 @@ func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64)
11021116 v.liveRenderCache = make(map[liveToolCacheKey][]string)
11031117 }
11041118 key := liveToolCacheKey{
1119+ callID: tc.ID,
11051120 revision: tc.Revision,
11061121 content: hashLiveTool(tc),
11071122 width: width,
@@ -1253,6 +1268,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
12531268 // high-water mark is per call id and is dropped the moment a
12541269 // Result lands (this branch only runs while Result == "").
12551270 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+ }
12561278 if v.liveBodyHigh != nil {
12571279 if prev := v.liveBodyHigh[tc.ID]; prev > high {
12581280 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 uint64238 revision uint64
238 content uint64239 content uint64
239 width int240 width int
@@ -390,7 +391,7 @@ func (v *View) Build(width int) []string {
390 // scrollback renderers can keep these rows outside the immutable transcript391 // 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) so537 // 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, anchors651 return out, anchors
666 }652 }
667 653
668-// prepareLiveBodyHeights resets live tool height reservations when ctrl+o654+// prepareLiveToolState drops cached frames and height reservations that no
669-// changes expansion state. Expanded content must not keep a collapsed box655+// 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.ExpandAll660 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 the692 // 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 a1268 // 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 = prev1280 high = prev
M packages/tui/view_tool_overlay_test.go +74 -0
@@ -82,6 +82,61 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) {
8282 }
8383 }
8484
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+
85140 func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
86141 v := View{Theme: Dark}
87142 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
@@ -105,6 +160,25 @@ func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
105160 }
106161 }
107162
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+
108182 func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
109183 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
110184 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{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close