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
14 changes: 14 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,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).
Expand Down
12 changes: 12 additions & 0 deletions internal/keybindings/keybindings.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,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",
Expand Down
2 changes: 1 addition & 1 deletion internal/keybindings/keybindings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
102 changes: 95 additions & 7 deletions internal/panels/help/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -68,24 +70,58 @@ 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).
if i < len(secs)-1 {
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
}

Expand Down Expand Up @@ -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)
Expand All @@ -173,31 +215,77 @@ 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{
{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"},
{Key: keyEscape, Description: "Close help", Action: "close"},
}
}

// ---------------------------------------------------------------------------
// 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 keyEscape, "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 keyEscape, "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
// ---------------------------------------------------------------------------
Expand Down
36 changes: 34 additions & 2 deletions internal/panels/help/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -243,18 +243,50 @@ 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 {
keys[b.Key] = true
}
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
// ---------------------------------------------------------------------------
Expand Down
Loading