Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 42 additions & 22 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.MouseButtonWheelDown:
if m.mouseInPreview {
m.previewScroll += 3
m.previewScroll = min(m.previewScroll+3, m.maxPreviewScroll())
} else {
if m.cursor < len(m.filtered)-1 {
m.cursor++
Expand Down Expand Up @@ -231,7 +231,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil

case "pgdown", "ctrl+j":
m.previewScroll += 10
m.previewScroll = min(m.previewScroll+10, m.maxPreviewScroll())
return m, nil

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

func (m model) renderPreview(item listItem, height int) string {
query := m.textInput.Value()
conv := item.conv

// Fixed header (always visible)
var header []string
header = append(header, "\033[1;33mProject:\033[0m "+highlight(conv.Cwd, query))
if conv.Title != "" {
header = append(header, "\033[1;33mName:\033[0m "+highlight(conv.Title, query))
}
header = append(header, "\033[1;33mSession:\033[0m "+highlight(conv.SessionID, query))
header = append(header, "")

// Build message lines (scrollable)
// buildPreviewLines builds the scrollable message lines of a conversation
// preview (everything below the fixed header). Shared by renderPreview and
// maxPreviewScroll so the render and the scroll-clamp can never disagree on how
// far the preview can scroll.
func buildPreviewLines(conv Conversation, query string) []string {
var msgLines []string

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

// Apply scroll to messages only (header stays fixed)
return msgLines
}

// maxPreviewScroll is the furthest the preview of the current selection can
// scroll - one line short of the rendered message-line count.
func (m model) maxPreviewScroll() int {
if len(m.filtered) == 0 {
return 0
}
lines := buildPreviewLines(m.filtered[m.cursor].conv, m.textInput.Value())
return max(0, len(lines)-1)
}

func (m model) renderPreview(item listItem, height int) string {
query := m.textInput.Value()
conv := item.conv

// Fixed header (always visible)
var header []string
header = append(header, "\033[1;33mProject:\033[0m "+highlight(conv.Cwd, query))
if conv.Title != "" {
header = append(header, "\033[1;33mName:\033[0m "+highlight(conv.Title, query))
}
header = append(header, "\033[1;33mSession:\033[0m "+highlight(conv.SessionID, query))
header = append(header, "")

msgLines := buildPreviewLines(conv, query)

// Apply scroll to messages only (header stays fixed). Clamp locally for this
// render; the persisted m.previewScroll is bounded in Update via
// maxPreviewScroll (this method has a value receiver, so a write here would
// be discarded).
msgHeight := height - len(header)
if msgHeight < 1 {
msgHeight = 1
}
if m.previewScroll >= len(msgLines) {
m.previewScroll = max(0, len(msgLines)-1)
}
end := min(m.previewScroll+msgHeight, len(msgLines))
visibleMsgLines := msgLines[m.previewScroll:end]
scroll := min(m.previewScroll, max(0, len(msgLines)-1))
end := min(scroll+msgHeight, len(msgLines))
visibleMsgLines := msgLines[scroll:end]

// Combine header + scrolled messages
allLines := append(header, visibleMsgLines...)
Expand Down
40 changes: 37 additions & 3 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -992,10 +992,15 @@ func TestRenderPreviewLongMultibyteMessageStaysValidUTF8(t *testing.T) {
}

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

m := initialModel(items, "", nil)
Expand Down Expand Up @@ -1038,6 +1043,35 @@ func TestUpdateMouseScroll(t *testing.T) {
}
}

func TestPreviewScrollClampedToContent(t *testing.T) {
conv := Conversation{SessionID: "s1", Messages: []Message{
{Role: "user", Text: "only message", Ts: "2024-01-15T10:00:00Z"},
}}
m := initialModel([]listItem{{conv: conv}}, "", nil)
m.mouseInPreview = true

maxScroll := m.maxPreviewScroll()

// Hammer pgdown far past the content; previewScroll must never exceed max.
for i := 0; i < 100; i++ {
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
m = result.(model)
if m.previewScroll > maxScroll {
t.Fatalf("previewScroll %d exceeded max %d after pgdown", m.previewScroll, maxScroll)
}
}
if m.previewScroll != maxScroll {
t.Errorf("previewScroll should settle at max %d, got %d", maxScroll, m.previewScroll)
}

// A single pgup from the bottom must visibly move (no dead scroll-up zone).
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgUp})
m = result.(model)
if maxScroll > 0 && m.previewScroll >= maxScroll {
t.Errorf("pgup should move up from max; stuck at %d", m.previewScroll)
}
}

func TestDeleteConversationFullFlow(t *testing.T) {
// Create temp directory that will act as projects dir
tmpDir := t.TempDir()
Expand Down
Loading