fix(tui): keep multiple inline images visible
Also route up/down arrows to chat scrolling even when the editor has text, while preserving slash-popup navigation.
- commit
- 43aca0b9b2c648e62ac79ccaaaa07b5eaae4fffc
- author
- patriceckhart · · 2026-04-21 19:24 UTC
- parent
- c16e929
- signature
- unsigned
- refs
- tag: v0.0.86
3 files · +50 -11
M internal/agent/modes/interactive.go +40 -6
| @@ -809,6 +809,7 @@ func (i *Interactive) redraw() { | |||
| 809 | 809 | if start < 0 { | |
| 810 | 810 | start = 0 | |
| 811 | 811 | } | |
| 812 | + | start = alignSliceStartToImageBlock(chat, start, end) | |
| 812 | 813 | visibleChat = chat[start:end] | |
| 813 | 814 | } | |
| 814 | 815 | ||
| @@ -852,6 +853,38 @@ func (i *Interactive) redraw() { | |||
| 852 | 853 | i.rend.Draw(frame, cursorRow, cursorCol) | |
| 853 | 854 | } | |
| 854 | 855 | ||
| 856 | + | func alignSliceStartToImageBlock(chat []string, start, end int) int { | |
| 857 | + | if start <= 0 || start >= len(chat) || start >= end { | |
| 858 | + | return start | |
| 859 | + | } | |
| 860 | + | // If the slice already starts on an image row, keep it. | |
| 861 | + | if strings.Contains(chat[start], "\x1b]1337;File=") || strings.Contains(chat[start], "\x1b_G") { | |
| 862 | + | return start | |
| 863 | + | } | |
| 864 | + | // When the viewport begins inside an image block, the image escape row | |
| 865 | + | // sits just above a run of blank reservation rows, followed by an | |
| 866 | + | // "image - ..." info line. In that case, snap the slice start up to the | |
| 867 | + | // escape row so the image actually renders instead of showing only the | |
| 868 | + | // reserved blank area and metadata. | |
| 869 | + | j := start | |
| 870 | + | for j < end && strings.TrimSpace(chat[j]) == "" { | |
| 871 | + | j++ | |
| 872 | + | } | |
| 873 | + | if j >= end || !strings.Contains(chat[j], "image - ") { | |
| 874 | + | return start | |
| 875 | + | } | |
| 876 | + | for k := start - 1; k >= 0; k-- { | |
| 877 | + | line := chat[k] | |
| 878 | + | if strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") { | |
| 879 | + | return k | |
| 880 | + | } | |
| 881 | + | if strings.TrimSpace(line) != "" { | |
| 882 | + | break | |
| 883 | + | } | |
| 884 | + | } | |
| 885 | + | return start | |
| 886 | + | } | |
| 887 | + | ||
| 855 | 888 | // truncateLine shortens s so it fits within n display cells, with an | |
| 856 | 889 | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | |
| 857 | 890 | // novel doesn't blow past the status line. | |
| @@ -1166,16 +1199,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1166 | 1199 | i.scrollBy(-i.chatPage()) | |
| 1167 | 1200 | return false | |
| 1168 | 1201 | case tui.KeyUp: | |
| 1169 | - | // Wheel-up in alt screen sends Up arrows on most terminals. | |
| 1170 | - | // When the editor is empty we use up/down for chat scroll — | |
| 1171 | - | // independently of whether the agent is busy, so users can | |
| 1172 | - | // scroll back through long streaming replies while they run. | |
| 1173 | - | if i.ed.IsEmpty() { | |
| 1202 | + | // Always use up/down for chat scrolling, even when the editor | |
| 1203 | + | // contains text. This makes keyboard scrolling consistent with | |
| 1204 | + | // a draft present at the cost of disabling vertical cursor | |
| 1205 | + | // motion inside the multi-line editor. Keep slash-popup | |
| 1206 | + | // navigation working by letting it intercept later when active. | |
| 1207 | + | if !i.suggest.Active(i.ed.Value()) { | |
| 1174 | 1208 | i.scrollBy(+3) | |
| 1175 | 1209 | return false | |
| 1176 | 1210 | } | |
| 1177 | 1211 | case tui.KeyDown: | |
| 1178 | - | if i.ed.IsEmpty() { | |
| 1212 | + | if !i.suggest.Active(i.ed.Value()) { | |
| 1179 | 1213 | if i.scrollOffset > 0 { | |
| 1180 | 1214 | i.scrollBy(-3) | |
| 1181 | 1215 | } | |
| @@ -809,6 +809,7 @@ func (i *Interactive) redraw() { | |||||
| 809 | if start < 0 { | 809 | if start < 0 { | ||
| 810 | start = 0 | 810 | start = 0 | ||
| 811 | } | 811 | } | ||
| 812 | + | start = alignSliceStartToImageBlock(chat, start, end) | |||
| 812 | visibleChat = chat[start:end] | 813 | visibleChat = chat[start:end] | ||
| 813 | } | 814 | } | ||
| 814 | 815 | ||||
| @@ -852,6 +853,38 @@ func (i *Interactive) redraw() { | |||||
| 852 | i.rend.Draw(frame, cursorRow, cursorCol) | 853 | i.rend.Draw(frame, cursorRow, cursorCol) | ||
| 853 | } | 854 | } | ||
| 854 | 855 | ||||
| 856 | + | func alignSliceStartToImageBlock(chat []string, start, end int) int { | |||
| 857 | + | if start <= 0 || start >= len(chat) || start >= end { | |||
| 858 | + | return start | |||
| 859 | + | } | |||
| 860 | + | // If the slice already starts on an image row, keep it. | |||
| 861 | + | if strings.Contains(chat[start], "\x1b]1337;File=") || strings.Contains(chat[start], "\x1b_G") { | |||
| 862 | + | return start | |||
| 863 | + | } | |||
| 864 | + | // When the viewport begins inside an image block, the image escape row | |||
| 865 | + | // sits just above a run of blank reservation rows, followed by an | |||
| 866 | + | // "image - ..." info line. In that case, snap the slice start up to the | |||
| 867 | + | // escape row so the image actually renders instead of showing only the | |||
| 868 | + | // reserved blank area and metadata. | |||
| 869 | + | j := start | |||
| 870 | + | for j < end && strings.TrimSpace(chat[j]) == "" { | |||
| 871 | + | j++ | |||
| 872 | + | } | |||
| 873 | + | if j >= end || !strings.Contains(chat[j], "image - ") { | |||
| 874 | + | return start | |||
| 875 | + | } | |||
| 876 | + | for k := start - 1; k >= 0; k-- { | |||
| 877 | + | line := chat[k] | |||
| 878 | + | if strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") { | |||
| 879 | + | return k | |||
| 880 | + | } | |||
| 881 | + | if strings.TrimSpace(line) != "" { | |||
| 882 | + | break | |||
| 883 | + | } | |||
| 884 | + | } | |||
| 885 | + | return start | |||
| 886 | + | } | |||
| 887 | + | ||||
| 855 | // truncateLine shortens s so it fits within n display cells, with an | 888 | // truncateLine shortens s so it fits within n display cells, with an | ||
| 856 | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | 889 | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | ||
| 857 | // novel doesn't blow past the status line. | 890 | // novel doesn't blow past the status line. | ||
| @@ -1166,16 +1199,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1166 | i.scrollBy(-i.chatPage()) | 1199 | i.scrollBy(-i.chatPage()) | ||
| 1167 | return false | 1200 | return false | ||
| 1168 | case tui.KeyUp: | 1201 | case tui.KeyUp: | ||
| 1169 | - | // Wheel-up in alt screen sends Up arrows on most terminals. | 1202 | + | // Always use up/down for chat scrolling, even when the editor |
| 1170 | - | // When the editor is empty we use up/down for chat scroll — | 1203 | + | // contains text. This makes keyboard scrolling consistent with |
| 1171 | - | // independently of whether the agent is busy, so users can | 1204 | + | // a draft present at the cost of disabling vertical cursor |
| 1172 | - | // scroll back through long streaming replies while they run. | 1205 | + | // motion inside the multi-line editor. Keep slash-popup |
| 1173 | - | if i.ed.IsEmpty() { | 1206 | + | // navigation working by letting it intercept later when active. |
| 1207 | + | if !i.suggest.Active(i.ed.Value()) { | |||
| 1174 | i.scrollBy(+3) | 1208 | i.scrollBy(+3) | ||
| 1175 | return false | 1209 | return false | ||
| 1176 | } | 1210 | } | ||
| 1177 | case tui.KeyDown: | 1211 | case tui.KeyDown: | ||
| 1178 | - | if i.ed.IsEmpty() { | 1212 | + | if !i.suggest.Active(i.ed.Value()) { |
| 1179 | if i.scrollOffset > 0 { | 1213 | if i.scrollOffset > 0 { | ||
| 1180 | i.scrollBy(-3) | 1214 | i.scrollBy(-3) | ||
| 1181 | } | 1215 | } | ||
M internal/tui/image.go +0 -4
| @@ -123,10 +123,6 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | |||
| 123 | 123 | const chunk = 4096 | |
| 124 | 124 | var sb strings.Builder | |
| 125 | 125 | ||
| 126 | - | // Prefix: delete any previously-placed images so old frames don't | |
| 127 | - | // linger on screen when the chat scrolls past them. | |
| 128 | - | sb.WriteString("\x1b_Ga=d\x1b\\") | |
| 129 | - | ||
| 130 | 126 | // Pick the most constraining dimension and use only it. Kitty | |
| 131 | 127 | // preserves aspect ratio when exactly one of c/r is provided. | |
| 132 | 128 | hdr := "a=T,f=100" | |
| @@ -123,10 +123,6 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | |||||
| 123 | const chunk = 4096 | 123 | const chunk = 4096 | ||
| 124 | var sb strings.Builder | 124 | var sb strings.Builder | ||
| 125 | 125 | ||||
| 126 | - | // Prefix: delete any previously-placed images so old frames don't | |||
| 127 | - | // linger on screen when the chat scrolls past them. | |||
| 128 | - | sb.WriteString("\x1b_Ga=d\x1b\\") | |||
| 129 | - | ||||
| 130 | // Pick the most constraining dimension and use only it. Kitty | 126 | // Pick the most constraining dimension and use only it. Kitty | ||
| 131 | // preserves aspect ratio when exactly one of c/r is provided. | 127 | // preserves aspect ratio when exactly one of c/r is provided. | ||
| 132 | hdr := "a=T,f=100" | 128 | hdr := "a=T,f=100" | ||
M internal/tui/render.go +10 -1
| @@ -152,15 +152,24 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 152 | 152 | // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES; | |
| 153 | 153 | // the common code path below is the fast cached diff. | |
| 154 | 154 | curHasImage := false | |
| 155 | + | curHasKittyImage := false | |
| 155 | 156 | for _, l := range frame { | |
| 156 | 157 | if containsImageEscape(l) { | |
| 157 | 158 | curHasImage = true | |
| 158 | - | break | |
| 159 | + | if strings.Contains(l, "\x1b_G") { | |
| 160 | + | curHasKittyImage = true | |
| 161 | + | } | |
| 159 | 162 | } | |
| 160 | 163 | } | |
| 161 | 164 | forceAll := curHasImage || r.prevHadImage | |
| 162 | 165 | if forceAll { | |
| 163 | 166 | w.WriteString(SeqClearScreen) | |
| 167 | + | if curHasKittyImage { | |
| 168 | + | // Delete previously placed kitty images once per frame, | |
| 169 | + | // before rewriting all rows. Doing this inside each image | |
| 170 | + | // escape makes only the last image in the frame survive. | |
| 171 | + | w.WriteString("\x1b_Ga=d\x1b\\") | |
| 172 | + | } | |
| 164 | 173 | } | |
| 165 | 174 | ||
| 166 | 175 | full := r.prev == nil || len(r.prev) != r.rows | |
| @@ -152,15 +152,24 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 152 | // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES; | 152 | // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES; | ||
| 153 | // the common code path below is the fast cached diff. | 153 | // the common code path below is the fast cached diff. | ||
| 154 | curHasImage := false | 154 | curHasImage := false | ||
| 155 | + | curHasKittyImage := false | |||
| 155 | for _, l := range frame { | 156 | for _, l := range frame { | ||
| 156 | if containsImageEscape(l) { | 157 | if containsImageEscape(l) { | ||
| 157 | curHasImage = true | 158 | curHasImage = true | ||
| 158 | - | break | 159 | + | if strings.Contains(l, "\x1b_G") { |
| 160 | + | curHasKittyImage = true | |||
| 161 | + | } | |||
| 159 | } | 162 | } | ||
| 160 | } | 163 | } | ||
| 161 | forceAll := curHasImage || r.prevHadImage | 164 | forceAll := curHasImage || r.prevHadImage | ||
| 162 | if forceAll { | 165 | if forceAll { | ||
| 163 | w.WriteString(SeqClearScreen) | 166 | w.WriteString(SeqClearScreen) | ||
| 167 | + | if curHasKittyImage { | |||
| 168 | + | // Delete previously placed kitty images once per frame, | |||
| 169 | + | // before rewriting all rows. Doing this inside each image | |||
| 170 | + | // escape makes only the last image in the frame survive. | |||
| 171 | + | w.WriteString("\x1b_Ga=d\x1b\\") | |||
| 172 | + | } | |||
| 164 | } | 173 | } | ||
| 165 | 174 | ||||
| 166 | full := r.prev == nil || len(r.prev) != r.rows | 175 | full := r.prev == nil || len(r.prev) != r.rows | ||