detached 43aca0b

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() {
809809 if start < 0 {
810810 start = 0
811811 }
812+ start = alignSliceStartToImageBlock(chat, start, end)
812813 visibleChat = chat[start:end]
813814 }
814815
@@ -852,6 +853,38 @@ func (i *Interactive) redraw() {
852853 i.rend.Draw(frame, cursorRow, cursorCol)
853854 }
854855
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+
855888 // truncateLine shortens s so it fits within n display cells, with an
856889 // ellipsis if trimmed. Used by the "sliding in" chips so a pasted
857890 // novel doesn't blow past the status line.
@@ -1166,16 +1199,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
11661199 i.scrollBy(-i.chatPage())
11671200 return false
11681201 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()) {
11741208 i.scrollBy(+3)
11751209 return false
11761210 }
11771211 case tui.KeyDown:
1178- if i.ed.IsEmpty() {
1212+ if !i.suggest.Active(i.ed.Value()) {
11791213 if i.scrollOffset > 0 {
11801214 i.scrollBy(-3)
11811215 }
@@ -809,6 +809,7 @@ func (i *Interactive) redraw() {
809 if start < 0 {809 if start < 0 {
810 start = 0810 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 an888 // truncateLine shortens s so it fits within n display cells, with an
856 // ellipsis if trimmed. Used by the "sliding in" chips so a pasted889 // 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 false1200 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 can1204+ // 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 false1209 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 {
123123 const chunk = 4096
124124 var sb strings.Builder
125125
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-
130126 // Pick the most constraining dimension and use only it. Kitty
131127 // preserves aspect ratio when exactly one of c/r is provided.
132128 hdr := "a=T,f=100"
@@ -123,10 +123,6 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
123 const chunk = 4096123 const chunk = 4096
124 var sb strings.Builder124 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. Kitty126 // 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) {
152152 // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
153153 // the common code path below is the fast cached diff.
154154 curHasImage := false
155+ curHasKittyImage := false
155156 for _, l := range frame {
156157 if containsImageEscape(l) {
157158 curHasImage = true
158- break
159+ if strings.Contains(l, "\x1b_G") {
160+ curHasKittyImage = true
161+ }
159162 }
160163 }
161164 forceAll := curHasImage || r.prevHadImage
162165 if forceAll {
163166 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+ }
164173 }
165174
166175 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 := false154 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 = true158 curHasImage = true
158- break159+ if strings.Contains(l, "\x1b_G") {
160+ curHasKittyImage = true
161+ }
159 }162 }
160 }163 }
161 forceAll := curHasImage || r.prevHadImage164 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.rows175 full := r.prev == nil || len(r.prev) != r.rows

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