diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 2d696a2..8eb45e7 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -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) } diff --git a/internal/tui/image.go b/internal/tui/image.go index ab24921..2fee4bd 100644 --- a/internal/tui/image.go +++ b/internal/tui/image.go @@ -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" diff --git a/internal/tui/render.go b/internal/tui/render.go index 91e2547..08a7a43 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -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