Skip to content

Commit ae9ab0b

Browse files
authored
Merge pull request #11 from agentic-utils/fix/preview-scroll-clamp
fix: clamp preview scroll so over-scrolling doesn't dead-zone scroll-up
2 parents d1e2404 + 5da2119 commit ae9ab0b

2 files changed

Lines changed: 79 additions & 25 deletions

File tree

main.go

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163163
return m, nil
164164
case tea.MouseButtonWheelDown:
165165
if m.mouseInPreview {
166-
m.previewScroll += 3
166+
m.previewScroll = min(m.previewScroll+3, m.maxPreviewScroll())
167167
} else {
168168
if m.cursor < len(m.filtered)-1 {
169169
m.cursor++
@@ -231,7 +231,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
231231
return m, nil
232232

233233
case "pgdown", "ctrl+j":
234-
m.previewScroll += 10
234+
m.previewScroll = min(m.previewScroll+10, m.maxPreviewScroll())
235235
return m, nil
236236

237237
case "ctrl+u":
@@ -393,20 +393,11 @@ func (m model) formatListItem(item listItem, selected bool) string {
393393
ts, project, topic, msgs, hits)
394394
}
395395

396-
func (m model) renderPreview(item listItem, height int) string {
397-
query := m.textInput.Value()
398-
conv := item.conv
399-
400-
// Fixed header (always visible)
401-
var header []string
402-
header = append(header, "\033[1;33mProject:\033[0m "+highlight(conv.Cwd, query))
403-
if conv.Title != "" {
404-
header = append(header, "\033[1;33mName:\033[0m "+highlight(conv.Title, query))
405-
}
406-
header = append(header, "\033[1;33mSession:\033[0m "+highlight(conv.SessionID, query))
407-
header = append(header, "")
408-
409-
// Build message lines (scrollable)
396+
// buildPreviewLines builds the scrollable message lines of a conversation
397+
// preview (everything below the fixed header). Shared by renderPreview and
398+
// maxPreviewScroll so the render and the scroll-clamp can never disagree on how
399+
// far the preview can scroll.
400+
func buildPreviewLines(conv Conversation, query string) []string {
410401
var msgLines []string
411402

412403
// Find messages containing the query
@@ -495,16 +486,45 @@ func (m model) renderPreview(item listItem, height int) string {
495486
msgLines = append(msgLines, fmt.Sprintf("\033[90m ... %d more messages\033[0m", remaining))
496487
}
497488

498-
// Apply scroll to messages only (header stays fixed)
489+
return msgLines
490+
}
491+
492+
// maxPreviewScroll is the furthest the preview of the current selection can
493+
// scroll - one line short of the rendered message-line count.
494+
func (m model) maxPreviewScroll() int {
495+
if len(m.filtered) == 0 {
496+
return 0
497+
}
498+
lines := buildPreviewLines(m.filtered[m.cursor].conv, m.textInput.Value())
499+
return max(0, len(lines)-1)
500+
}
501+
502+
func (m model) renderPreview(item listItem, height int) string {
503+
query := m.textInput.Value()
504+
conv := item.conv
505+
506+
// Fixed header (always visible)
507+
var header []string
508+
header = append(header, "\033[1;33mProject:\033[0m "+highlight(conv.Cwd, query))
509+
if conv.Title != "" {
510+
header = append(header, "\033[1;33mName:\033[0m "+highlight(conv.Title, query))
511+
}
512+
header = append(header, "\033[1;33mSession:\033[0m "+highlight(conv.SessionID, query))
513+
header = append(header, "")
514+
515+
msgLines := buildPreviewLines(conv, query)
516+
517+
// Apply scroll to messages only (header stays fixed). Clamp locally for this
518+
// render; the persisted m.previewScroll is bounded in Update via
519+
// maxPreviewScroll (this method has a value receiver, so a write here would
520+
// be discarded).
499521
msgHeight := height - len(header)
500522
if msgHeight < 1 {
501523
msgHeight = 1
502524
}
503-
if m.previewScroll >= len(msgLines) {
504-
m.previewScroll = max(0, len(msgLines)-1)
505-
}
506-
end := min(m.previewScroll+msgHeight, len(msgLines))
507-
visibleMsgLines := msgLines[m.previewScroll:end]
525+
scroll := min(m.previewScroll, max(0, len(msgLines)-1))
526+
end := min(scroll+msgHeight, len(msgLines))
527+
visibleMsgLines := msgLines[scroll:end]
508528

509529
// Combine header + scrolled messages
510530
allLines := append(header, visibleMsgLines...)

main_test.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -992,10 +992,15 @@ func TestRenderPreviewLongMultibyteMessageStaysValidUTF8(t *testing.T) {
992992
}
993993

994994
func TestUpdateMouseScroll(t *testing.T) {
995+
// Give each conversation enough messages that the preview is scrollable.
996+
msgs := make([]Message, 10)
997+
for i := range msgs {
998+
msgs[i] = Message{Role: "user", Text: "message text line", Ts: "2024-01-15T10:00:00Z"}
999+
}
9951000
items := []listItem{
996-
{conv: Conversation{SessionID: "test-1"}, searchText: "first"},
997-
{conv: Conversation{SessionID: "test-2"}, searchText: "second"},
998-
{conv: Conversation{SessionID: "test-3"}, searchText: "third"},
1001+
{conv: Conversation{SessionID: "test-1", Messages: msgs}, searchText: "first"},
1002+
{conv: Conversation{SessionID: "test-2", Messages: msgs}, searchText: "second"},
1003+
{conv: Conversation{SessionID: "test-3", Messages: msgs}, searchText: "third"},
9991004
}
10001005

10011006
m := initialModel(items, "", nil)
@@ -1038,6 +1043,35 @@ func TestUpdateMouseScroll(t *testing.T) {
10381043
}
10391044
}
10401045

1046+
func TestPreviewScrollClampedToContent(t *testing.T) {
1047+
conv := Conversation{SessionID: "s1", Messages: []Message{
1048+
{Role: "user", Text: "only message", Ts: "2024-01-15T10:00:00Z"},
1049+
}}
1050+
m := initialModel([]listItem{{conv: conv}}, "", nil)
1051+
m.mouseInPreview = true
1052+
1053+
maxScroll := m.maxPreviewScroll()
1054+
1055+
// Hammer pgdown far past the content; previewScroll must never exceed max.
1056+
for i := 0; i < 100; i++ {
1057+
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
1058+
m = result.(model)
1059+
if m.previewScroll > maxScroll {
1060+
t.Fatalf("previewScroll %d exceeded max %d after pgdown", m.previewScroll, maxScroll)
1061+
}
1062+
}
1063+
if m.previewScroll != maxScroll {
1064+
t.Errorf("previewScroll should settle at max %d, got %d", maxScroll, m.previewScroll)
1065+
}
1066+
1067+
// A single pgup from the bottom must visibly move (no dead scroll-up zone).
1068+
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgUp})
1069+
m = result.(model)
1070+
if maxScroll > 0 && m.previewScroll >= maxScroll {
1071+
t.Errorf("pgup should move up from max; stuck at %d", m.previewScroll)
1072+
}
1073+
}
1074+
10411075
func TestDeleteConversationFullFlow(t *testing.T) {
10421076
// Create temp directory that will act as projects dir
10431077
tmpDir := t.TempDir()

0 commit comments

Comments
 (0)