From 17eb799cf9c3bd55b66773bf87d64e94372e3dad Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:46:56 -0700 Subject: [PATCH 1/2] feat(help): filter shortcuts in help overlay Closes #346 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 14 ++++ internal/keybindings/keybindings.json | 12 +++ internal/keybindings/keybindings_test.go | 2 +- internal/panels/help/help.go | 98 ++++++++++++++++++++++-- internal/panels/help/help_test.go | 34 +++++++- 5 files changed, 152 insertions(+), 8 deletions(-) diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3bf..46f1c9fa 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -219,6 +219,20 @@ Diff viewer panel for file, commit, branch, and PR diffs. --- +## Help Overlay + +Active when the help overlay is open. + +| Key | Action | +|-----|--------| +| `/` | Filter shortcuts by section, key, or action | +| `j/k` | Scroll help content | +| `Enter` | Keep the current filter and leave filter input | +| `Esc` | Clear filter, or close help when no filter is active | +| `?` | Close help | + +--- + ## Edit Mode Active when editing a file inline (press e in Preview to enter). diff --git a/internal/keybindings/keybindings.json b/internal/keybindings/keybindings.json index 6c9e7045..2eb522cc 100644 --- a/internal/keybindings/keybindings.json +++ b/internal/keybindings/keybindings.json @@ -191,6 +191,18 @@ { "key": "0", "action": "Reset diff context to default" } ] }, + { + "id": "help", + "title": "Help Overlay", + "description": "Active when the help overlay is open.", + "bindings": [ + { "key": "/", "action": "Filter shortcuts by section, key, or action" }, + { "key": "j/k", "action": "Scroll help content" }, + { "key": "Enter", "action": "Keep the current filter and leave filter input" }, + { "key": "Esc", "action": "Clear filter, or close help when no filter is active" }, + { "key": "?", "action": "Close help" } + ] + }, { "id": "edit_mode", "title": "Edit Mode", diff --git a/internal/keybindings/keybindings_test.go b/internal/keybindings/keybindings_test.go index 1ca99744..128abea1 100644 --- a/internal/keybindings/keybindings_test.go +++ b/internal/keybindings/keybindings_test.go @@ -34,7 +34,7 @@ func TestSections_ExpectedSections(t *testing.T) { for i, s := range secs { ids[i] = s.ID } - expected := []string{"global", "navigation", "filetree", "gitstatus", "gitinfo", "github", "commits", "preview", "gitdiff", "edit_mode"} + expected := []string{"global", "navigation", "filetree", "gitstatus", "gitinfo", "github", "commits", "preview", "gitdiff", "help", "edit_mode"} assert.Equal(t, expected, ids) } diff --git a/internal/panels/help/help.go b/internal/panels/help/help.go index d149b1ee..193472dd 100644 --- a/internal/panels/help/help.go +++ b/internal/panels/help/help.go @@ -45,9 +45,11 @@ func initColors(th *theme.Theme) panelColors { type Panel struct { lines []string // pre-rendered content lines (unstyled text) panels.BasePanel - colors panelColors - theme *theme.Theme - offset int // scroll offset + colors panelColors + theme *theme.Theme + filterMode bool + filterQuery string + offset int // scroll offset } // Compile-time interface check. @@ -68,15 +70,42 @@ func New(th *theme.Theme) *Panel { // Lines are stored as plain text; styling is applied during rendering. func (p *Panel) buildLines() { secs := keybindings.Sections() + query := strings.ToLower(strings.TrimSpace(p.filterQuery)) var lines []string lines = append(lines, "") // top padding + + if p.filterMode || query != "" { + display := p.filterQuery + if display == "" { + display = "type to filter" + } + lines = append(lines, "filter:Filter: "+display) + lines = append(lines, "") + } + + matchedSections := 0 for i, sec := range secs { + sectionMatches := query == "" || strings.Contains(strings.ToLower(sec.Title), query) + matchingBindings := sec.Bindings + if query != "" && !sectionMatches { + matchingBindings = nil + for _, b := range sec.Bindings { + haystack := strings.ToLower(b.Key + " " + b.Action) + if strings.Contains(haystack, query) { + matchingBindings = append(matchingBindings, b) + } + } + } + if query != "" && !sectionMatches && len(matchingBindings) == 0 { + continue + } + matchedSections++ // Section title. lines = append(lines, "section:"+sec.Title) // Separator under title. lines = append(lines, "sep:"+strings.Repeat("─", len(sec.Title))) // Bindings. - for _, b := range sec.Bindings { + for _, b := range matchingBindings { lines = append(lines, "bind:"+b.Key+"\t"+b.Action) } // Blank line between sections (except after the last). @@ -84,8 +113,15 @@ func (p *Panel) buildLines() { lines = append(lines, "") } } + if matchedSections == 0 { + lines = append(lines, "text:No matching keybindings") + } lines = append(lines, "") // bottom padding - lines = append(lines, "footer:Press ? or Esc to close") + if p.filterMode || query != "" { + lines = append(lines, "footer:Enter keeps filter, Esc clears, ? closes") + } else { + lines = append(lines, "footer:Press / to filter, ? or Esc to close") + } p.lines = lines } @@ -158,6 +194,12 @@ func (p *Panel) View(width, height int) string { padded = key + strings.Repeat(" ", 12-keyWidth) } styled = " " + keyStyle.Render(padded) + descStyle.Render(desc) + case strings.HasPrefix(line, "filter:"): + text := strings.TrimPrefix(line, "filter:") + styled = " " + dimStyle.Render(text) + case strings.HasPrefix(line, "text:"): + text := strings.TrimPrefix(line, "text:") + styled = " " + descStyle.Render(text) case strings.HasPrefix(line, "footer:"): text := strings.TrimPrefix(line, "footer:") styled = " " + dimStyle.Render(text) @@ -178,6 +220,7 @@ func (p *Panel) KeyBindings() []panels.KeyBinding { return []panels.KeyBinding{ {Key: "j/↓", Description: "Scroll down", Action: "scroll_down"}, {Key: "k/↑", Description: "Scroll up", Action: "scroll_up"}, + {Key: "/", Description: "Filter shortcuts", Action: "filter"}, {Key: "?", Description: "Close help", Action: "close"}, {Key: "escape", Description: "Close help", Action: "close"}, } @@ -187,17 +230,60 @@ func (p *Panel) KeyBindings() []panels.KeyBinding { // Key handling // --------------------------------------------------------------------------- func (p *Panel) handleKey(msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) { + if p.filterMode { + switch msg.String() { + case "enter": + p.filterMode = false + p.rebuildFromFilter() + return p, nil + case "escape", "esc": + p.clearFilter() + return p, nil + case "backspace": + if r := []rune(p.filterQuery); len(r) > 0 { + p.filterQuery = string(r[:len(r)-1]) + p.rebuildFromFilter() + } + return p, nil + default: + if msg.Text != "" { + p.filterQuery += msg.Text + p.rebuildFromFilter() + } + return p, nil + } + } switch msg.String() { case "j", "down": p.scrollDown() case "k", "up": p.scrollUp() - case "escape", "esc", "?": + case "/": + p.filterMode = true + p.rebuildFromFilter() + case "escape", "esc": + if p.filterQuery != "" { + p.clearFilter() + return p, nil + } + return p, func() tea.Msg { return panels.ToggleHelpMsg{} } + case "?": return p, func() tea.Msg { return panels.ToggleHelpMsg{} } } return p, nil } +func (p *Panel) rebuildFromFilter() { + p.offset = 0 + p.buildLines() +} + +func (p *Panel) clearFilter() { + p.filterMode = false + p.filterQuery = "" + p.rebuildFromFilter() +} + // --------------------------------------------------------------------------- // Mouse handling // --------------------------------------------------------------------------- diff --git a/internal/panels/help/help_test.go b/internal/panels/help/help_test.go index 2a36d050..12d4ff80 100644 --- a/internal/panels/help/help_test.go +++ b/internal/panels/help/help_test.go @@ -243,7 +243,7 @@ func TestKeyBindings(t *testing.T) { p := New(nil) bindings := p.KeyBindings() assert.NotEmpty(t, bindings) - assert.Len(t, bindings, 4) + assert.Len(t, bindings, 5) keys := make(map[string]bool) for _, b := range bindings { @@ -251,10 +251,42 @@ func TestKeyBindings(t *testing.T) { } assert.True(t, keys["j/↓"]) assert.True(t, keys["k/↑"]) + assert.True(t, keys["/"]) assert.True(t, keys["?"]) assert.True(t, keys["escape"]) } +func TestFilter_NarrowsHelpContent(t *testing.T) { + p := newTestPanel(t) + + p.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + p.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + p.Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + p.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + p.Update(tea.KeyPressMsg{Code: 'h', Text: "h"}) + + view := p.View(80, 80) + assert.Contains(t, view, "Filter: push") + assert.Contains(t, view, "Push to remote") + assert.NotContains(t, view, "Toggle AI chat") +} + +func TestFilter_EscapeClearsBeforeClose(t *testing.T) { + p := newTestPanel(t) + + p.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + p.Update(tea.KeyPressMsg{Code: 'b', Text: "b"}) + _, cmd := p.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.Nil(t, cmd) + assert.Empty(t, p.filterQuery) + assert.False(t, p.filterMode) + + _, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.NotNil(t, cmd) + _, ok := cmd().(panels.ToggleHelpMsg) + assert.True(t, ok) +} + // --------------------------------------------------------------------------- // Content has all major sections // --------------------------------------------------------------------------- From 4e370e10976bae0e6d392d821a4db73624f8b065 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:35:22 -0700 Subject: [PATCH 2/2] fix(help): extract escape key constant and align close-hint test with filter footer Fixes the goconst failure and the stale TestView_ContainsCloseHint assertion that did not match the new 'Press / to filter, ? or Esc to close' footer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1e7a2e9-8a0a-42dc-be96-9ac694fc55c5 --- internal/panels/help/help.go | 8 +++++--- internal/panels/help/help_test.go | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/panels/help/help.go b/internal/panels/help/help.go index 193472dd..c9453d2d 100644 --- a/internal/panels/help/help.go +++ b/internal/panels/help/help.go @@ -215,6 +215,8 @@ func (p *Panel) View(width, height int) string { return strings.Join(rendered, "\n") } +const keyEscape = "escape" + // KeyBindings implements panels.Panel. func (p *Panel) KeyBindings() []panels.KeyBinding { return []panels.KeyBinding{ @@ -222,7 +224,7 @@ func (p *Panel) KeyBindings() []panels.KeyBinding { {Key: "k/↑", Description: "Scroll up", Action: "scroll_up"}, {Key: "/", Description: "Filter shortcuts", Action: "filter"}, {Key: "?", Description: "Close help", Action: "close"}, - {Key: "escape", Description: "Close help", Action: "close"}, + {Key: keyEscape, Description: "Close help", Action: "close"}, } } @@ -236,7 +238,7 @@ func (p *Panel) handleKey(msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) { p.filterMode = false p.rebuildFromFilter() return p, nil - case "escape", "esc": + case keyEscape, "esc": p.clearFilter() return p, nil case "backspace": @@ -261,7 +263,7 @@ func (p *Panel) handleKey(msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) { case "/": p.filterMode = true p.rebuildFromFilter() - case "escape", "esc": + case keyEscape, "esc": if p.filterQuery != "" { p.clearFilter() return p, nil diff --git a/internal/panels/help/help_test.go b/internal/panels/help/help_test.go index 12d4ff80..558f6aa4 100644 --- a/internal/panels/help/help_test.go +++ b/internal/panels/help/help_test.go @@ -105,7 +105,7 @@ func TestView_ContainsPreviewSection(t *testing.T) { func TestView_ContainsCloseHint(t *testing.T) { p := newTestPanel(t) view := p.View(60, 200) - assert.Contains(t, view, "Press ? or Esc to close") + assert.Contains(t, view, "? or Esc to close") } func TestView_ContainsKeyBindings(t *testing.T) {