diff --git a/CLAUDE.md b/CLAUDE.md index 9bd9735..55219a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/footer_completeness_test.go b/footer_completeness_test.go new file mode 100644 index 0000000..7b780aa --- /dev/null +++ b/footer_completeness_test.go @@ -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) + } + } +} diff --git a/project.go b/project.go index 31e7866..5801cc4 100644 --- a/project.go +++ b/project.go @@ -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") } diff --git a/render.go b/render.go index 772648f..4fca4d2 100644 --- a/render.go +++ b/render.go @@ -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)) } @@ -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. @@ -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 ----- @@ -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. @@ -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 ----- @@ -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 } diff --git a/render_test.go b/render_test.go index d68b9dd..4cb3997 100644 --- a/render_test.go +++ b/render_test.go @@ -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) } }