From e64f164936d7fea459a9070d5115195feb2a3457 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Fri, 8 May 2026 10:43:58 -0600 Subject: [PATCH 1/3] =?UTF-8?q?test(red):=20footer=20completeness=20?= =?UTF-8?q?=E2=80=94=20=3F=20help=20and=20missing=20keys=20in=20all=20mode?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify: - Every mode footer shows "?" so users can discover the help overlay. - List footer shows "m bookmark", "M bookmarks", "T timeline". - Detail footer shows "m bookmark" and "/ search". All 10 new assertions currently fail. Co-Authored-By: Claude Sonnet 4.6 --- footer_completeness_test.go | 146 ++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 footer_completeness_test.go 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) + } + } +} From 650f14748d24a86886aaa70b6733faa4a3963c55 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Fri, 8 May 2026 10:44:55 -0600 Subject: [PATCH 2/3] feat(green): add ? help hint and missing keys to all mode footers Every footer now shows "? help" so users can discover the help overlay. List footer gains "m bookmark", "M bookmarks", "T timeline" and shortens "p filter project" -> "p project", "b filter branch" -> "b branch", "S usage stats" -> "S stats". Detail footer gains "m bookmark" and "/ search". All other mode footers gain "? help". Also updates the stale assertions in TestRenderFooter_DefaultFooter to match the new shorter labels. Co-Authored-By: Claude Sonnet 4.6 --- project.go | 2 +- render.go | 12 ++++++------ render_test.go | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) 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) } } From d4decf0ba33a7a694798d41cc72bab1678586b07 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Fri, 8 May 2026 10:47:20 -0600 Subject: [PATCH 3/3] docs: note ? help is universal in footer format description CLAUDE.md Rendering & View section now states that every footer includes "? help" alongside the existing back-nav / quit conventions. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.