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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Tests import `bubbletea` directly to send messages (e.g., `keyMsg()`) to the mod

`View()` dispatches by `m.mode` to the per-mode renderer. Each renderer follows the same shape: header, divider, body lines (sliced through `clampOffset` + `sliceLines` from `viewport.go`), divider, footer. The `?` help overlay short-circuits the entire view.

Every mode has dedicated `render*Header` and `render*Footer` functions (no inline header/footer construction inside the View renderer). Footer hints follow a uniform `key action` format separated by three spaces; sub-views all show `q/esc/h/← back`, while the list shows `q quit`. Flash messages are rendered through one path in every footer (precedence over hints).
Every mode has dedicated `render*Header` and `render*Footer` functions (no inline header/footer construction inside the View renderer). Footer hints follow a uniform `key action` format separated by three spaces; every footer includes `? help` so the overlay is discoverable; sub-views also show `q/esc/h/← back`, while the list shows `q quit`. Flash messages are rendered through one path in every footer (precedence over hints).

Styling is done via Lipgloss `NewStyle()` instances defined at the top of `render.go`. Layout constants (`projectColWidth`, `branchColWidth`, `fixedCols`, `rerunMaxLines`, `snippetMaxLen`) are package-level so list and search rows render identical column widths.

Expand Down
146 changes: 146 additions & 0 deletions footer_completeness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package lore

// footer_completeness_test.go: every mode's footer must show ? help and the
// keys that are absent from the current footer strings but present in the
// help overlay (renderHelpOverlay).

import (
"strings"
"testing"
"time"
)

// TestAllFooters_HaveHelpHint ensures every mode's steady-state footer shows
// "? help" so users can discover the help overlay.
func TestAllFooters_HaveHelpHint(t *testing.T) {
cases := []struct {
name string
get func() string
}{
{
name: "list",
get: func() string {
m := loadedModelWith(Session{Project: "p", Slug: "s1", Timestamp: time.Now()})
m.width = 220
m.height = 40
return renderListFooter(m)
},
},
{
name: "detail",
get: func() string {
m := loadedModel("a")
m.mode = modeDetail
m.detailSession = Session{Slug: "x", Project: "p", Branch: "b", Timestamp: time.Now()}
m.turns = []turn{{kind: "user", body: "hi"}}
m.expandedTurns = make(map[int]bool)
m.width = 220
m.height = 40
return renderDetailFooter(m)
},
},
{
name: "search results",
get: func() string {
m := newModel("/d")
m.mode = modeSearch
m.searchMode = searchModeResults
m.searchQuery = "x"
m.width = 220
m.height = 40
return renderSearchFooter(m)
},
},
{
name: "project",
get: func() string {
m := newModel("/d")
m.mode = modeProject
m.projectCWD = "/x/p"
m.width = 220
m.height = 40
return renderProjectFooter(m)
},
},
{
name: "rerun",
get: func() string {
m := newModel("/d")
m.mode = modeRerun
m.detailSession = Session{Slug: "x"}
m.rerunPrompt = "hi"
m.rerunCWD = "/x"
m.width = 220
m.height = 40
return renderRerunFooter(m)
},
},
{
name: "stats",
get: func() string {
m := loadedModelWith(Session{Project: "p", Slug: "s1", Timestamp: time.Now()})
m.mode = modeStats
m.statsData = []statsRow{}
m.width = 220
m.height = 40
return renderStatsFooter(m)
},
},
{
name: "timeline",
get: func() string {
m := newModel("/d")
m.mode = modeTimeline
m.width = 220
m.height = 40
return renderTimelineFooter(m)
},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := tc.get()
if !strings.Contains(out, "?") {
t.Errorf("%s footer missing '?' help hint:\n%s", tc.name, out)
}
})
}
}

// TestListFooter_HasBookmarkAndTimelineHints checks that m (bookmark toggle),
// M (bookmark-only filter), and T (timeline) are all shown in the default
// list footer. These are first-class features that users should be able to
// discover without pressing ?.
func TestListFooter_HasBookmarkAndTimelineHints(t *testing.T) {
m := loadedModelWith(Session{Project: "p", Slug: "s1", Timestamp: time.Now()})
m.width = 220
m.height = 40
out := renderListFooter(m)

for _, want := range []string{"m bookmark", "M bookmarks", "T timeline"} {
if !strings.Contains(out, want) {
t.Errorf("list footer missing %q:\n%s", want, out)
}
}
}

// TestDetailFooter_HasBookmarkAndSearchHints checks that m (bookmark) and /
// (search) appear in the detail footer. Both are shown in the help overlay
// but were absent from the footer hint bar.
func TestDetailFooter_HasBookmarkAndSearchHints(t *testing.T) {
m := loadedModel("a")
m.mode = modeDetail
m.detailSession = Session{Slug: "x", Project: "p", Branch: "b", Timestamp: time.Now()}
m.turns = []turn{{kind: "user", body: "hi"}}
m.expandedTurns = make(map[int]bool)
m.width = 220
m.height = 40
out := renderDetailFooter(m)

for _, want := range []string{"m bookmark", "/ search"} {
if !strings.Contains(out, want) {
t.Errorf("detail footer missing %q:\n%s", want, out)
}
}
}
2 changes: 1 addition & 1 deletion project.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,5 @@ func renderProjectFooter(m model) string {
if m.flashMsg != "" {
return flashStyle.Render(" " + m.flashMsg)
}
return footerStyle.Render(" j/k move d/u page enter open g/G top/bottom q/esc/h/← back")
return footerStyle.Render(" j/k move d/u page enter open g/G top/bottom ? help q/esc/h/← back")
}
12 changes: 6 additions & 6 deletions render.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ func renderDetailFooter(m model) string {
copyStatus = " ✓ copied"
}
return footerStyle.Render(fmt.Sprintf(
" j/k move d/u page g/G top/bottom space expand y copy r run q/esc/h/← back%s",
" j/k move d/u page g/G top/bottom space expand y copy r run m bookmark / search ? help q/esc/h/← back%s",
copyStatus))
}

Expand Down Expand Up @@ -421,7 +421,7 @@ func renderListFooter(m model) string {
return footerStyle.Render(fmt.Sprintf(" fuzzy filter: %s j/k · enter open · esc clear q quit", m.filterText))
}
}
return footerStyle.Render(" j/k move d/u page enter open / search p filter project b filter branch f fuzzy P project view S usage stats g/G top/bottom q quit")
return footerStyle.Render(" j/k move d/u page enter open / search p project b branch f fuzzy m bookmark M bookmarks T timeline P project view S stats g/G top/bottom ? help q quit")
}

// padTrunc trims s to max display columns or right-pads it to fit.
Expand Down Expand Up @@ -527,7 +527,7 @@ func renderSearchFooter(m model) string {
if m.searchMode == searchModeEntry {
return footerStyle.Render(" search: " + m.searchQuery + "_ [enter] run [esc] cancel")
}
return footerStyle.Render(" j/k move d/u page enter open / new search g/G top/bottom q/esc/h/← back")
return footerStyle.Render(" j/k move d/u page enter open / new search g/G top/bottom ? help q/esc/h/← back")
}

// ----- re-run -----
Expand Down Expand Up @@ -585,7 +585,7 @@ func renderRerunFooter(m model) string {
if m.flashMsg != "" {
return flashStyle.Render(" " + m.flashMsg)
}
return footerStyle.Render(" enter run q/esc/h/← back")
return footerStyle.Render(" enter run ? help q/esc/h/← back")
}

// extractToolName extracts the tool name from the tool body string.
Expand Down Expand Up @@ -928,7 +928,7 @@ func renderStatsFooter(m model) string {
if m.flashMsg != "" {
return flashStyle.Render(" " + m.flashMsg)
}
return footerStyle.Render(" j/k move d/u page g/G top/bottom q/esc/h/← back")
return footerStyle.Render(" j/k move d/u page g/G top/bottom ? help q/esc/h/← back")
}

// ----- timeline mode -----
Expand Down Expand Up @@ -973,7 +973,7 @@ func renderTimelineFooter(m model) string {
hm := buildHeatmap(m.sessions, time.Now())
count := hm.countOn(m.timelineCursor)
dateStr := m.timelineCursor.Format("2006-01-02 (Mon)")
hint := footerStyle.Render(" h/← l/→ move day enter filter list q/esc back")
hint := footerStyle.Render(" h/← l/→ move day enter filter list ? help q/esc back")
info := footerStyle.Render(fmt.Sprintf(" %s %d session%s", dateStr, count, plural(count)))
return info + "\n" + hint
}
Expand Down
8 changes: 4 additions & 4 deletions render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,11 @@ func TestRenderFooter_DefaultFooter(t *testing.T) {
if !strings.Contains(out, "j/k move") {
t.Errorf("default footer missing 'j/k move':\n%s", out)
}
if !strings.Contains(out, "p filter project") {
t.Errorf("default footer missing 'p filter project':\n%s", out)
if !strings.Contains(out, "p project") {
t.Errorf("default footer missing 'p project':\n%s", out)
}
if !strings.Contains(out, "b filter branch") {
t.Errorf("default footer missing 'b filter branch':\n%s", out)
if !strings.Contains(out, "b branch") {
t.Errorf("default footer missing 'b branch':\n%s", out)
}
}

Expand Down
Loading