fix(tui): atomic image scroll skip + live tool overlay indent
- commit
- 9afd1238a3ca5a3dc2cd9c45bc510eda9ac45707
- author
- patriceckhart · · 2026-04-26 09:40 UTC
- parent
- a81472c
- signature
- unsigned
- refs
- tag: v0.1.18
2 files · +30 -12
M internal/agent/modes/interactive.go +23 -4
| @@ -190,6 +190,7 @@ type Interactive struct { | |||
| 190 | 190 | dirty chan struct{} | |
| 191 | 191 | cancelTurn context.CancelFunc | |
| 192 | 192 | scrollOffset int // rows from the bottom; 0 = pinned to latest | |
| 193 | + | prevScrollOffset int // last value redraw snapped against; tracks intent | |
| 193 | 194 | ||
| 194 | 195 | // Messages typed while a turn is in flight. Each is delivered as | |
| 195 | 196 | // its own follow-up turn once the current one finishes. Rendered | |
| @@ -830,11 +831,28 @@ func (i *Interactive) redraw() { | |||
| 830 | 831 | visibleChat = chat | |
| 831 | 832 | } else { | |
| 832 | 833 | end := len(chat) - i.scrollOffset | |
| 833 | - | start := end - chatRows | |
| 834 | - | if start < 0 { | |
| 835 | - | start = 0 | |
| 834 | + | rawStart := end - chatRows | |
| 835 | + | if rawStart < 0 { | |
| 836 | + | rawStart = 0 | |
| 837 | + | } | |
| 838 | + | start := snapViewportStartToImageBlock(chat, rawStart) | |
| 839 | + | // If the snap pulled start upward (an image-block was atomic) while | |
| 840 | + | // the user is scrolling downward, the viewport would sit on the same | |
| 841 | + | // image until the user mashes down past every reserved row. Bump | |
| 842 | + | // scrollOffset past the image so one keypress always clears it. | |
| 843 | + | if start < rawStart && i.scrollOffset < i.prevScrollOffset { | |
| 844 | + | jump := rawStart - start | |
| 845 | + | i.scrollOffset -= jump | |
| 846 | + | if i.scrollOffset < 0 { | |
| 847 | + | i.scrollOffset = 0 | |
| 848 | + | } | |
| 849 | + | end = len(chat) - i.scrollOffset | |
| 850 | + | rawStart = end - chatRows | |
| 851 | + | if rawStart < 0 { | |
| 852 | + | rawStart = 0 | |
| 853 | + | } | |
| 854 | + | start = snapViewportStartToImageBlock(chat, rawStart) | |
| 836 | 855 | } | |
| 837 | - | start = snapViewportStartToImageBlock(chat, start) | |
| 838 | 856 | end = start + chatRows | |
| 839 | 857 | if end > len(chat) { | |
| 840 | 858 | end = len(chat) | |
| @@ -845,6 +863,7 @@ func (i *Interactive) redraw() { | |||
| 845 | 863 | } | |
| 846 | 864 | visibleChat = chat[start:end] | |
| 847 | 865 | } | |
| 866 | + | i.prevScrollOffset = i.scrollOffset | |
| 848 | 867 | visibleChat = clipBottomClippedImages(visibleChat) | |
| 849 | 868 | ||
| 850 | 869 | // A tiny "scrolled up" indicator in the top-right of the chat pane | |
| @@ -190,6 +190,7 @@ type Interactive struct { | |||||
| 190 | dirty chan struct{} | 190 | dirty chan struct{} | ||
| 191 | cancelTurn context.CancelFunc | 191 | cancelTurn context.CancelFunc | ||
| 192 | scrollOffset int // rows from the bottom; 0 = pinned to latest | 192 | scrollOffset int // rows from the bottom; 0 = pinned to latest | ||
| 193 | + | prevScrollOffset int // last value redraw snapped against; tracks intent | |||
| 193 | 194 | ||||
| 194 | // Messages typed while a turn is in flight. Each is delivered as | 195 | // Messages typed while a turn is in flight. Each is delivered as | ||
| 195 | // its own follow-up turn once the current one finishes. Rendered | 196 | // its own follow-up turn once the current one finishes. Rendered | ||
| @@ -830,11 +831,28 @@ func (i *Interactive) redraw() { | |||||
| 830 | visibleChat = chat | 831 | visibleChat = chat | ||
| 831 | } else { | 832 | } else { | ||
| 832 | end := len(chat) - i.scrollOffset | 833 | end := len(chat) - i.scrollOffset | ||
| 833 | - | start := end - chatRows | 834 | + | rawStart := end - chatRows |
| 834 | - | if start < 0 { | 835 | + | if rawStart < 0 { |
| 835 | - | start = 0 | 836 | + | rawStart = 0 |
| 837 | + | } | |||
| 838 | + | start := snapViewportStartToImageBlock(chat, rawStart) | |||
| 839 | + | // If the snap pulled start upward (an image-block was atomic) while | |||
| 840 | + | // the user is scrolling downward, the viewport would sit on the same | |||
| 841 | + | // image until the user mashes down past every reserved row. Bump | |||
| 842 | + | // scrollOffset past the image so one keypress always clears it. | |||
| 843 | + | if start < rawStart && i.scrollOffset < i.prevScrollOffset { | |||
| 844 | + | jump := rawStart - start | |||
| 845 | + | i.scrollOffset -= jump | |||
| 846 | + | if i.scrollOffset < 0 { | |||
| 847 | + | i.scrollOffset = 0 | |||
| 848 | + | } | |||
| 849 | + | end = len(chat) - i.scrollOffset | |||
| 850 | + | rawStart = end - chatRows | |||
| 851 | + | if rawStart < 0 { | |||
| 852 | + | rawStart = 0 | |||
| 853 | + | } | |||
| 854 | + | start = snapViewportStartToImageBlock(chat, rawStart) | |||
| 836 | } | 855 | } | ||
| 837 | - | start = snapViewportStartToImageBlock(chat, start) | |||
| 838 | end = start + chatRows | 856 | end = start + chatRows | ||
| 839 | if end > len(chat) { | 857 | if end > len(chat) { | ||
| 840 | end = len(chat) | 858 | end = len(chat) | ||
| @@ -845,6 +863,7 @@ func (i *Interactive) redraw() { | |||||
| 845 | } | 863 | } | ||
| 846 | visibleChat = chat[start:end] | 864 | visibleChat = chat[start:end] | ||
| 847 | } | 865 | } | ||
| 866 | + | i.prevScrollOffset = i.scrollOffset | |||
| 848 | visibleChat = clipBottomClippedImages(visibleChat) | 867 | visibleChat = clipBottomClippedImages(visibleChat) | ||
| 849 | 868 | ||||
| 850 | // A tiny "scrolled up" indicator in the top-right of the chat pane | 869 | // A tiny "scrolled up" indicator in the top-right of the chat pane | ||
M internal/tui/view.go +7 -8
| @@ -511,7 +511,10 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 511 | 511 | if arg == "" && tc.LivePath != "" { | |
| 512 | 512 | arg = tc.LivePath | |
| 513 | 513 | } | |
| 514 | - | head := v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg) | |
| 514 | + | // Match the transcript-side framing: tool headers sit indented four | |
| 515 | + | // cells under the assistant body column. The live overlay used to skip | |
| 516 | + | // the indent which made streaming tools jump back to column 0. | |
| 517 | + | head := " " + v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg) | |
| 515 | 518 | ||
| 516 | 519 | // Live streaming body: pulled out of the partial JSON buffer for | |
| 517 | 520 | // tools whose interesting content is a string field (currently | |
| @@ -570,8 +573,8 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||
| 570 | 573 | } | |
| 571 | 574 | // Header line hints which edit is streaming and, when more | |
| 572 | 575 | // than one has landed, how many the model is doing. | |
| 573 | - | hint := fmt.Sprintf(" edit %d (streaming)", idx) | |
| 574 | - | body := []string{v.Theme.FG256(v.Theme.Muted, hint), ""} | |
| 576 | + | hint := fmt.Sprintf("edit %d (streaming)", idx) | |
| 577 | + | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} | |
| 575 | 578 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | |
| 576 | 579 | return v.wrapLiveBody(body, width) | |
| 577 | 580 | } | |
| @@ -893,14 +896,10 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||
| 893 | 896 | out = append(out, "") | |
| 894 | 897 | } | |
| 895 | 898 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | |
| 896 | - | out = append(out, "") | |
| 897 | 899 | return out | |
| 898 | 900 | } | |
| 899 | 901 | } | |
| 900 | - | // Text fallback: same trailing blank for symmetry with the | |
| 901 | - | // inline path, so a text-only image placeholder also gets | |
| 902 | - | // a little space below before the next content. | |
| 903 | - | return []string{v.Theme.FG256(v.Theme.Muted, info), ""} | |
| 902 | + | return []string{v.Theme.FG256(v.Theme.Muted, info)} | |
| 904 | 903 | } | |
| 905 | 904 | ||
| 906 | 905 | // looksLikeNumberedFile returns true when text matches the `read` | |
| @@ -511,7 +511,10 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 511 | if arg == "" && tc.LivePath != "" { | 511 | if arg == "" && tc.LivePath != "" { | ||
| 512 | arg = tc.LivePath | 512 | arg = tc.LivePath | ||
| 513 | } | 513 | } | ||
| 514 | - | head := v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg) | 514 | + | // Match the transcript-side framing: tool headers sit indented four |
| 515 | + | // cells under the assistant body column. The live overlay used to skip | |||
| 516 | + | // the indent which made streaming tools jump back to column 0. | |||
| 517 | + | head := " " + v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg) | |||
| 515 | 518 | ||||
| 516 | // Live streaming body: pulled out of the partial JSON buffer for | 519 | // Live streaming body: pulled out of the partial JSON buffer for | ||
| 517 | // tools whose interesting content is a string field (currently | 520 | // tools whose interesting content is a string field (currently | ||
| @@ -570,8 +573,8 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||||
| 570 | } | 573 | } | ||
| 571 | // Header line hints which edit is streaming and, when more | 574 | // Header line hints which edit is streaming and, when more | ||
| 572 | // than one has landed, how many the model is doing. | 575 | // than one has landed, how many the model is doing. | ||
| 573 | - | hint := fmt.Sprintf(" edit %d (streaming)", idx) | 576 | + | hint := fmt.Sprintf("edit %d (streaming)", idx) |
| 574 | - | body := []string{v.Theme.FG256(v.Theme.Muted, hint), ""} | 577 | + | body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""} |
| 575 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | 578 | body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...) | ||
| 576 | return v.wrapLiveBody(body, width) | 579 | return v.wrapLiveBody(body, width) | ||
| 577 | } | 580 | } | ||
| @@ -893,14 +896,10 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||||
| 893 | out = append(out, "") | 896 | out = append(out, "") | ||
| 894 | } | 897 | } | ||
| 895 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | 898 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | ||
| 896 | - | out = append(out, "") | |||
| 897 | return out | 899 | return out | ||
| 898 | } | 900 | } | ||
| 899 | } | 901 | } | ||
| 900 | - | // Text fallback: same trailing blank for symmetry with the | 902 | + | return []string{v.Theme.FG256(v.Theme.Muted, info)} |
| 901 | - | // inline path, so a text-only image placeholder also gets | |||
| 902 | - | // a little space below before the next content. | |||
| 903 | - | return []string{v.Theme.FG256(v.Theme.Muted, info), ""} | |||
| 904 | } | 903 | } | ||
| 905 | 904 | ||||
| 906 | // looksLikeNumberedFile returns true when text matches the `read` | 905 | // looksLikeNumberedFile returns true when text matches the `read` | ||