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.
This commit is contained in:
patriceckhart 2026-04-21 21:24:11 +02:00
parent c16e929f32
commit 43aca0b9b2
3 changed files with 50 additions and 11 deletions

View file

@ -809,6 +809,7 @@ func (i *Interactive) redraw() {
if start < 0 {
start = 0
}
start = alignSliceStartToImageBlock(chat, start, end)
visibleChat = chat[start:end]
}
@ -852,6 +853,38 @@ func (i *Interactive) redraw() {
i.rend.Draw(frame, cursorRow, cursorCol)
}
func alignSliceStartToImageBlock(chat []string, start, end int) int {
if start <= 0 || start >= len(chat) || start >= end {
return start
}
// If the slice already starts on an image row, keep it.
if strings.Contains(chat[start], "\x1b]1337;File=") || strings.Contains(chat[start], "\x1b_G") {
return start
}
// When the viewport begins inside an image block, the image escape row
// sits just above a run of blank reservation rows, followed by an
// "image - ..." info line. In that case, snap the slice start up to the
// escape row so the image actually renders instead of showing only the
// reserved blank area and metadata.
j := start
for j < end && strings.TrimSpace(chat[j]) == "" {
j++
}
if j >= end || !strings.Contains(chat[j], "image - ") {
return start
}
for k := start - 1; k >= 0; k-- {
line := chat[k]
if strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") {
return k
}
if strings.TrimSpace(line) != "" {
break
}
}
return start
}
// truncateLine shortens s so it fits within n display cells, with an
// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
// novel doesn't blow past the status line.
@ -1166,16 +1199,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
i.scrollBy(-i.chatPage())
return false
case tui.KeyUp:
// Wheel-up in alt screen sends Up arrows on most terminals.
// When the editor is empty we use up/down for chat scroll —
// independently of whether the agent is busy, so users can
// scroll back through long streaming replies while they run.
if i.ed.IsEmpty() {
// Always use up/down for chat scrolling, even when the editor
// contains text. This makes keyboard scrolling consistent with
// a draft present at the cost of disabling vertical cursor
// motion inside the multi-line editor. Keep slash-popup
// navigation working by letting it intercept later when active.
if !i.suggest.Active(i.ed.Value()) {
i.scrollBy(+3)
return false
}
case tui.KeyDown:
if i.ed.IsEmpty() {
if !i.suggest.Active(i.ed.Value()) {
if i.scrollOffset > 0 {
i.scrollBy(-3)
}

View file

@ -123,10 +123,6 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
const chunk = 4096
var sb strings.Builder
// Prefix: delete any previously-placed images so old frames don't
// linger on screen when the chat scrolls past them.
sb.WriteString("\x1b_Ga=d\x1b\\")
// Pick the most constraining dimension and use only it. Kitty
// preserves aspect ratio when exactly one of c/r is provided.
hdr := "a=T,f=100"

View file

@ -152,15 +152,24 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
// the common code path below is the fast cached diff.
curHasImage := false
curHasKittyImage := false
for _, l := range frame {
if containsImageEscape(l) {
curHasImage = true
break
if strings.Contains(l, "\x1b_G") {
curHasKittyImage = true
}
}
}
forceAll := curHasImage || r.prevHadImage
if forceAll {
w.WriteString(SeqClearScreen)
if curHasKittyImage {
// Delete previously placed kitty images once per frame,
// before rewriting all rows. Doing this inside each image
// escape makes only the last image in the frame survive.
w.WriteString("\x1b_Ga=d\x1b\\")
}
}
full := r.prev == nil || len(r.prev) != r.rows