detached 9afd123

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 {
190190 dirty chan struct{}
191191 cancelTurn context.CancelFunc
192192 scrollOffset int // rows from the bottom; 0 = pinned to latest
193+ prevScrollOffset int // last value redraw snapped against; tracks intent
193194
194195 // Messages typed while a turn is in flight. Each is delivered as
195196 // its own follow-up turn once the current one finishes. Rendered
@@ -830,11 +831,28 @@ func (i *Interactive) redraw() {
830831 visibleChat = chat
831832 } else {
832833 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)
836855 }
837- start = snapViewportStartToImageBlock(chat, start)
838856 end = start + chatRows
839857 if end > len(chat) {
840858 end = len(chat)
@@ -845,6 +863,7 @@ func (i *Interactive) redraw() {
845863 }
846864 visibleChat = chat[start:end]
847865 }
866+ i.prevScrollOffset = i.scrollOffset
848867 visibleChat = clipBottomClippedImages(visibleChat)
849868
850869 // 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.CancelFunc191 cancelTurn context.CancelFunc
192 scrollOffset int // rows from the bottom; 0 = pinned to latest192 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 as195 // Messages typed while a turn is in flight. Each is delivered as
195 // its own follow-up turn once the current one finishes. Rendered196 // its own follow-up turn once the current one finishes. Rendered
@@ -830,11 +831,28 @@ func (i *Interactive) redraw() {
830 visibleChat = chat831 visibleChat = chat
831 } else {832 } else {
832 end := len(chat) - i.scrollOffset833 end := len(chat) - i.scrollOffset
833- start := end - chatRows834+ rawStart := end - chatRows
834- if start < 0 {835+ if rawStart < 0 {
835- start = 0836+ 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 + chatRows856 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 pane869 // 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 {
511511 if arg == "" && tc.LivePath != "" {
512512 arg = tc.LivePath
513513 }
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)
515518
516519 // Live streaming body: pulled out of the partial JSON buffer for
517520 // tools whose interesting content is a string field (currently
@@ -570,8 +573,8 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
570573 }
571574 // Header line hints which edit is streaming and, when more
572575 // 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), ""}
575578 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
576579 return v.wrapLiveBody(body, width)
577580 }
@@ -893,14 +896,10 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
893896 out = append(out, "")
894897 }
895898 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
896- out = append(out, "")
897899 return out
898900 }
899901 }
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)}
904903 }
905904
906905 // 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.LivePath512 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 for519 // Live streaming body: pulled out of the partial JSON buffer for
517 // tools whose interesting content is a string field (currently520 // 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 more574 // 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 out899 return out
898 }900 }
899 }901 }
900- // Text fallback: same trailing blank for symmetry with the902+ 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`

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