From ed493c97d346f025f6e18d54d06d54dc3803f59b Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:41:08 +0500 Subject: [PATCH 1/6] feat(config): [picker] hints, default true (#67) --- internal/config/config.go | 7 ++++++- internal/config/config_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 209d4da..896d824 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,11 @@ type Picker struct { // and only layout today, is a flat fzf-style list with the filter // prompt below it and a background-highlighted selected row. Layout string `toml:"layout"` + + // Hints, when true (the default), draws the key-hint line under the + // list, built from the bound Actions. When false the line shows only + // the match count and any message. + Hints bool `toml:"hints"` } // pickerLayouts are the values Picker.Layout accepts, in the order the @@ -83,7 +88,7 @@ func defaultConfig() Config { IncludeHidden: false, History: History{MaxVisits: 1000}, Keys: Keys{Vim: false}, - Picker: Picker{Layout: "list"}, + Picker: Picker{Layout: "list", Hints: true}, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4f1d50b..5668f7a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,6 +41,9 @@ func assertDefaults(t *testing.T, cfg config.Config) { if cfg.Picker.Layout != "list" { t.Errorf("Picker.Layout = %q, want %q", cfg.Picker.Layout, "list") } + if !cfg.Picker.Hints { + t.Errorf("Picker.Hints = false, want true") + } } func TestLoadFromMissingFileIsDefaults(t *testing.T) { @@ -68,6 +71,7 @@ max_visits = 42 vim = true [picker] layout = "list" +hints = false ` cfg, err := config.LoadFrom(writeConfig(t, body)) if err != nil { @@ -89,6 +93,9 @@ layout = "list" if cfg.Picker.Layout != "list" { t.Errorf("Picker.Layout = %q, want %q", cfg.Picker.Layout, "list") } + if cfg.Picker.Hints { + t.Errorf("Picker.Hints = true, want false") + } } func TestLoadFromExcludeTildeExpansion(t *testing.T) { @@ -182,3 +189,15 @@ func TestLoadFromGroupedLayoutRejectedAsInvalid(t *testing.T) { t.Errorf("error = %q, want no removal message", err.Error()) } } + +func TestLoadFromPickerHintsMustBeBoolean(t *testing.T) { + _, err := config.LoadFrom(writeConfig(t, "[picker]\nhints = \"no\"\n")) + if err == nil { + t.Fatal(`LoadFrom hints = "no": got nil error, want error`) + } + for _, want := range []string{"Hints", "line 2"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to contain %q", err.Error(), want) + } + } +} From fe05faafcbf6842473dfaf3ace831e80d1d46944 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:41:53 +0500 Subject: [PATCH 2/6] feat(action): list the user's Actions in config order, ahead of the built-ins (#67) --- internal/action/action.go | 46 +++++++++++++++++++++----------- internal/action/action_test.go | 45 ++++++++++++++++++++----------- internal/action/builtins_test.go | 4 +-- internal/action/copy_test.go | 2 +- internal/config/actions.go | 29 +++++++++++++++++++- internal/config/actions_test.go | 40 ++++++++++++++++++++++++--- 6 files changed, 128 insertions(+), 38 deletions(-) diff --git a/internal/action/action.go b/internal/action/action.go index dfdbd2b..3d826bd 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -84,8 +84,11 @@ func (e *Error) Error() string { func (e *Error) Unwrap() error { return e.Err } // Merge applies user's overrides onto the built-in Actions and returns the -// result: the built-ins in their own order, then the user's other Actions -// sorted by name. It fails on the first invalid Action with an *Error. +// result in the order the Picker lists them: the user's own Actions first, +// in the order of order (the names of the user's tables as config.toml +// lists them; any it leaves out follow, sorted by name), then the +// built-ins in their own order. It fails on the first invalid Action with +// an *Error. // // A key a user table sets wins over an Action that only holds it by default, // which is left unbound as if the user had set its key to "". Two Actions @@ -94,23 +97,36 @@ func (e *Error) Unwrap() error { return e.Err } // // vim says whether the vim key map is on, the only one where a plain // printable key may be bound. -func Merge(user map[string]Override, vim bool) ([]Action, error) { - out := Builtins() - index := make(map[string]int, len(out)) - for i, a := range out { - index[a.Name] = i +func Merge(user map[string]Override, order []string, vim bool) ([]Action, error) { + var out []Action + index := make(map[string]int, len(user)+len(builtins)) + isBuiltin := make(map[string]bool, len(builtins)) + for _, a := range builtins { + isBuiltin[a.Name] = true } - var added []string - for name := range user { - if _, ok := index[name]; !ok { - added = append(added, name) + add := func(name string) { + if _, ok := user[name]; ok && !isBuiltin[name] { + if _, seen := index[name]; !seen { + index[name] = len(out) + out = append(out, Action{Name: name}) + } } } - sort.Strings(added) - for _, name := range added { - index[name] = len(out) - out = append(out, Action{Name: name}) + for _, name := range order { + add(name) + } + var rest []string + for name := range user { + rest = append(rest, name) + } + sort.Strings(rest) + for _, name := range rest { + add(name) + } + for _, a := range builtins { + index[a.Name] = len(out) + out = append(out, a) } for name, o := range user { diff --git a/internal/action/action_test.go b/internal/action/action_test.go index a90c2e1..b98f156 100644 --- a/internal/action/action_test.go +++ b/internal/action/action_test.go @@ -25,18 +25,21 @@ func names(as []Action) []string { return out } -func TestMerge_UserActionsFollowBuiltinsSortedByName(t *testing.T) { +func TestMerge_UserActionsComeFirstInConfigOrderThenBuiltins(t *testing.T) { withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}", Detach: true}) got, err := Merge(map[string]Override{ "zed": {Run: str("zed {path}")}, "code": {Key: str("ctrl+v"), Run: str("code {path}"), Detach: flag(true)}, - }, false) + "mid": {Run: str("mid")}, + }, []string{"zed", "files", "code"}, false) if err != nil { t.Fatalf("Merge: %v", err) } - if want := []string{"files", "code", "zed"}; !slices.Equal(names(got), want) { + // "files" in the order is a built-in and stays with them; "mid", which + // the order leaves out, follows the ones it lists. + if want := []string{"zed", "code", "mid", "files"}; !slices.Equal(names(got), want) { t.Fatalf("names = %v, want %v", names(got), want) } code := got[1] @@ -45,10 +48,22 @@ func TestMerge_UserActionsFollowBuiltinsSortedByName(t *testing.T) { } } +func TestMerge_AnOrderNamingTheSameActionTwiceListsItOnce(t *testing.T) { + withBuiltins(t) + + got, err := Merge(map[string]Override{"a": {Run: str("a")}}, []string{"a", "a", "nope"}, false) + if err != nil { + t.Fatalf("Merge: %v", err) + } + if want := []string{"a"}; !slices.Equal(names(got), want) { + t.Errorf("names = %v, want %v", names(got), want) + } +} + func TestMerge_OverridesABuiltinFieldByField(t *testing.T) { withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}", Detach: true}) - got, err := Merge(map[string]Override{"files": {Key: str("ctrl+f")}}, false) + got, err := Merge(map[string]Override{"files": {Key: str("ctrl+f")}}, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } @@ -62,7 +77,7 @@ func TestMerge_OverridesABuiltinFieldByField(t *testing.T) { func TestMerge_EmptyKeyUnbindsABuiltin(t *testing.T) { withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}"}) - got, err := Merge(map[string]Override{"files": {Key: str("")}}, false) + got, err := Merge(map[string]Override{"files": {Key: str("")}}, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } @@ -93,7 +108,7 @@ func TestMerge_Errors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withBuiltins(t) - _, err := Merge(tt.user, tt.vim) + _, err := Merge(tt.user, nil, tt.vim) var ae *Error if !errors.As(err, &ae) { t.Fatalf("err = %v, want *Error", err) @@ -108,7 +123,7 @@ func TestMerge_Errors(t *testing.T) { func TestMerge_UserKeyDisplacesABuiltinHoldingIt(t *testing.T) { withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}"}) - got, err := Merge(map[string]Override{"code": {Key: str("ctrl+o"), Run: str("code")}}, false) + got, err := Merge(map[string]Override{"code": {Key: str("ctrl+o"), Run: str("code")}}, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } @@ -127,7 +142,7 @@ func TestMerge_UserRebindingABuiltinDisplacesAnotherBuiltin(t *testing.T) { Action{Name: "remote", Key: "ctrl+r", Run: "open-remote {path}"}, ) - got, err := Merge(map[string]Override{"remote": {Key: str("enter")}}, false) + got, err := Merge(map[string]Override{"remote": {Key: str("enter")}}, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } @@ -146,7 +161,7 @@ func TestMerge_UserRebindingAnEarlierBuiltinDisplacesALaterOne(t *testing.T) { Action{Name: "b", Key: "ctrl+b", Run: "y"}, ) - got, err := Merge(map[string]Override{"a": {Key: str("ctrl+b")}}, false) + got, err := Merge(map[string]Override{"a": {Key: str("ctrl+b")}}, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } @@ -159,7 +174,7 @@ func TestMerge_TwoUserActionsOnEnterIsAnError(t *testing.T) { _, err := Merge(map[string]Override{ "code": {Key: str("enter"), Run: str("code {path}")}, "edit": {Key: str("enter"), Run: str("vi {path}")}, - }, false) + }, nil, false) var ae *Error if !errors.As(err, &ae) || ae.Name != "edit" || ae.Field != "key" { t.Fatalf("err = %v, want an *Error for edit's key", err) @@ -168,7 +183,7 @@ func TestMerge_TwoUserActionsOnEnterIsAnError(t *testing.T) { func TestMerge_PrintableKeyAllowedWithVim(t *testing.T) { withBuiltins(t) - got, err := Merge(map[string]Override{"a": {Key: str("?"), Run: str("x")}}, true) + got, err := Merge(map[string]Override{"a": {Key: str("?"), Run: str("x")}}, nil, true) if err != nil || got[0].Key != "?" { t.Fatalf("got %+v, %v", got, err) } @@ -176,7 +191,7 @@ func TestMerge_PrintableKeyAllowedWithVim(t *testing.T) { func TestMerge_JumpNeedsNoRun(t *testing.T) { withBuiltins(t, Action{Name: "jump", Key: "enter", Jump: true}) - if _, err := Merge(nil, false); err != nil { + if _, err := Merge(nil, nil, false); err != nil { t.Fatalf("Merge: %v", err) } } @@ -198,7 +213,7 @@ func TestMerge_JumpRebindsAndEnterGoesToAnotherAction(t *testing.T) { got, err := Merge(map[string]Override{ "jump": {Key: str("alt+enter")}, "code": {Key: str("enter"), Run: str("code {path}"), Detach: &detach}, - }, false) + }, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } @@ -212,7 +227,7 @@ func TestMerge_JumpRebindsAndEnterGoesToAnotherAction(t *testing.T) { } func TestMerge_EnterGoesToAnotherActionAndJumpIsLeftUnbound(t *testing.T) { - got, err := Merge(map[string]Override{"code": {Key: str("enter"), Run: str("code {path}")}}, false) + got, err := Merge(map[string]Override{"code": {Key: str("enter"), Run: str("code {path}")}}, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } @@ -226,7 +241,7 @@ func TestMerge_EnterGoesToAnotherActionAndJumpIsLeftUnbound(t *testing.T) { } func TestMerge_JumpMayBeLeftUnbound(t *testing.T) { - got, err := Merge(map[string]Override{"jump": {Key: str("")}}, false) + got, err := Merge(map[string]Override{"jump": {Key: str("")}}, nil, false) if err != nil || got[0].Name != "jump" || got[0].Key != "" || !got[0].Jump { t.Fatalf("got %+v, %v, want an unbound jump", got, err) } diff --git a/internal/action/builtins_test.go b/internal/action/builtins_test.go index dfbd2e3..64cb6be 100644 --- a/internal/action/builtins_test.go +++ b/internal/action/builtins_test.go @@ -88,7 +88,7 @@ func TestMerge_UserOverridesFilesAndEditor(t *testing.T) { got, err := action.Merge(map[string]action.Override{ "files": {Run: ptr("thunar {path}"), Key: ptr("ctrl+f")}, "editor": {Run: ptr("hx {path}")}, - }, false) + }, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } @@ -108,7 +108,7 @@ func TestMerge_FilesAndEditorCanBeUnbound(t *testing.T) { got, err := action.Merge(map[string]action.Override{ "files": {Key: ptr("")}, "editor": {Key: ptr("")}, - }, false) + }, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } diff --git a/internal/action/copy_test.go b/internal/action/copy_test.go index 5317af2..c1eb0bf 100644 --- a/internal/action/copy_test.go +++ b/internal/action/copy_test.go @@ -17,7 +17,7 @@ func TestBuiltins_CopyIsBoundToCtrlYWithNoCommand(t *testing.T) { func TestMerge_CopyKeepsCopyUntilTheUserSetsRun(t *testing.T) { merged := func(o action.Override) action.Action { t.Helper() - got, err := action.Merge(map[string]action.Override{"copy": o}, false) + got, err := action.Merge(map[string]action.Override{"copy": o}, nil, false) if err != nil { t.Fatalf("Merge: %v", err) } diff --git a/internal/config/actions.go b/internal/config/actions.go index 1c3fce6..49ae6e4 100644 --- a/internal/config/actions.go +++ b/internal/config/actions.go @@ -14,7 +14,7 @@ import ( // into c.ResolvedActions. data is config.toml's text, used only to name the // line an invalid Action sits on. func (c *Config) resolveActions(path string, data []byte) error { - resolved, err := action.Merge(c.Actions, c.Keys.Vim) + resolved, err := action.Merge(c.Actions, actionOrder(data), c.Keys.Vim) if err != nil { var ae *action.Error if errors.As(err, &ae) { @@ -33,6 +33,33 @@ var ( keyLine = regexp.MustCompile(`^\s*([A-Za-z0-9_-]+)\s*=`) ) +// actionOrder returns the names of the [actions.] tables in the order +// data declares them, which the Picker lists them in. A name TOML lets be +// declared another way (dotted keys under a bare [actions] table) is left +// out, and Merge puts it after the rest. +func actionOrder(data []byte) []string { + var names []string + for _, line := range strings.Split(string(data), "\n") { + m := tableHeader.FindStringSubmatch(line) + if m == nil { + continue + } + name, ok := strings.CutPrefix(m[1], "actions.") + if !ok { + continue + } + if strings.HasPrefix(name, `"`) { + unquoted, err := strconv.Unquote(name) + if err != nil { + continue + } + name = unquoted + } + names = append(names, name) + } + return names +} + // actionLine returns the 1-based line of field in the [actions.] // table of data, the table's header when it does not set field (an // override can leave a built-in's key alone yet still clash), or 0 when diff --git a/internal/config/actions_test.go b/internal/config/actions_test.go index 2e546c3..df6468c 100644 --- a/internal/config/actions_test.go +++ b/internal/config/actions_test.go @@ -1,6 +1,7 @@ package config_test import ( + "slices" "strings" "testing" @@ -28,7 +29,7 @@ jump = true {Name: "code", Key: "ctrl+v", Run: "code {path}", Detach: true}, {Name: "lazygit", Key: "ctrl+alt+l", Run: "lazygit", Jump: true}, } - got := cfg.ResolvedActions[len(cfg.ResolvedActions)-2:] + got := cfg.ResolvedActions[:2] for i := range want { if got[i] != want[i] { t.Errorf("action %d = %+v, want %+v", i, got[i], want[i]) @@ -79,8 +80,39 @@ func TestLoadFromPrintableActionKeyAllowedWithVim(t *testing.T) { if err != nil { t.Fatalf("LoadFrom: %v", err) } - last := cfg.ResolvedActions[len(cfg.ResolvedActions)-1] - if last.Name != "help" || last.Key != "?" { - t.Errorf("last action = %+v", last) + first := cfg.ResolvedActions[0] + if first.Name != "help" || first.Key != "?" { + t.Errorf("first action = %+v", first) + } +} + +func TestLoadFromActionsKeepConfigOrderBeforeTheBuiltins(t *testing.T) { + cfg, err := config.LoadFrom(writeConfig(t, ` +[actions.zed] +run = "zed {path}" + +[actions.editor] +run = "hx {path}" + +[actions."a b"] +run = "x" + +[actions.code] +run = "code {path}" +`)) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + + var got []string + for _, a := range cfg.ResolvedActions { + got = append(got, a.Name) + } + want := []string{"zed", "a b", "code"} + for _, a := range action.Builtins() { + want = append(want, a.Name) + } + if !slices.Equal(got, want) { + t.Errorf("order = %v, want %v", got, want) } } From d7bf40dd15107590d1e78da9a3f0099480a27cf1 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:43:12 +0500 Subject: [PATCH 3/6] feat(picker): build the footer key hints from the bound Actions (#67) --- internal/picker/model.go | 16 +++- internal/picker/picker.go | 7 +- internal/picker/view.go | 28 ------ internal/picker/view_footer.go | 110 ++++++++++++++++++++++++ internal/picker/view_footer_test.go | 129 ++++++++++++++++++++++++++++ internal/picker/view_test.go | 5 +- 6 files changed, 262 insertions(+), 33 deletions(-) create mode 100644 internal/picker/view_footer.go create mode 100644 internal/picker/view_footer_test.go diff --git a/internal/picker/model.go b/internal/picker/model.go index daa6827..400d809 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -38,6 +38,8 @@ type Model struct { layout Layout actions map[string]action.Action // keyed by Action.Key + hints []action.Action // the bound Actions in hint order + noHints bool // Options.HideHints runner action.Runner copy func(text string) (bool, error) @@ -79,9 +81,17 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model { layout = LayoutList } actions := make(map[string]action.Action, len(opts.Actions)) + var hints []action.Action for _, a := range opts.Actions { - if a.Key != "" { - actions[a.Key] = a + if a.Key == "" { + continue + } + actions[a.Key] = a + if a.Key == "enter" { + // Whatever holds enter heads the hints, as the way in. + hints = append([]action.Action{a}, hints...) + } else { + hints = append(hints, a) } } runner := opts.Runner @@ -94,6 +104,8 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model { } return Model{ actions: actions, + hints: hints, + noHints: opts.HideHints, runner: runner, copy: copyFn, rows: rows, diff --git a/internal/picker/picker.go b/internal/picker/picker.go index ac33466..efeef49 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -86,9 +86,14 @@ type Options struct { // LayoutList. Layout Layout - // Actions are the Actions bound to keys, as config resolves them. + // Actions are the Actions bound to keys, as config resolves them. The + // footer hints list them in this order, the one on enter first. Actions []action.Action + // HideHints leaves the key hints off the footer line, which still + // shows the match count and any message. + HideHints bool + // Runner starts the detached Actions, which leave the Picker open. The // zero value is action.ExecRunner. Runner action.Runner diff --git a/internal/picker/view.go b/internal/picker/view.go index e6d9b47..0c52e92 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -99,31 +99,3 @@ func (m Model) previewView(t theme, rows []hit, met Metrics, now time.Time) stri MaxHeight(met.ListHeight) // Height is a minimum; a tall body must not grow the box return box.Render(body.String()) } - -// footerView renders the rule, legend line (when there is room), keys line -// (when there is room) and the match count. -func (m Model) footerView(t theme, width, matched int, met Metrics) string { - var lines []string - lines = append(lines, t.rule_(width)) - if met.ShowLegend { - lines = append(lines, t.legend()) - } - if met.ShowKeys { - keys := t.keysLine("↑↓", "move", "enter", "jump", "esc", "cancel", "ctrl+u", "clear") - if m.vim { - keys = t.keysLine("j/k", "move", "f", "filter", "esc", "back/cancel", "enter", "jump", "q", "quit") - } - count := t.muted_().Render(fmt.Sprintf("%d/%d", matched, len(m.rows))) - gap := max(width-lipgloss.Width(keys)-lipgloss.Width(count), 1) - if m.message != "" { - colour := t.red - if m.messageOK { - colour = t.green - } - keys = t.fg(colour).Render(truncateName(m.message, max(width-lipgloss.Width(count)-1, 1))) - gap = max(width-lipgloss.Width(keys)-lipgloss.Width(count), 1) - } - lines = append(lines, keys+strings.Repeat(" ", gap)+count) - } - return strings.Join(lines, "\n") -} diff --git a/internal/picker/view_footer.go b/internal/picker/view_footer.go new file mode 100644 index 0000000..0f67bc9 --- /dev/null +++ b/internal/picker/view_footer.go @@ -0,0 +1,110 @@ +package picker + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" +) + +// hintSepWidth is the width of the " · " between two key hints. +const hintSepWidth = 3 + +// footerView renders the rule, legend line (when there is room), keys line +// (when there is room) and the match count. +func (m Model) footerView(t theme, width, matched int, met Metrics) string { + var lines []string + lines = append(lines, t.rule_(width)) + if met.ShowLegend { + lines = append(lines, t.legend()) + } + if met.ShowKeys { + lines = append(lines, m.keysLine(t, width, matched)) + } + return strings.Join(lines, "\n") +} + +// keysLine is the footer's one line: on the left the message, else the key +// hints, and on the right the match count. A message replaces the hints +// until the next key press. +func (m Model) keysLine(t theme, width, matched int) string { + right := t.muted_().Render(fmt.Sprintf("%d/%d", matched, len(m.rows))) + room := max(width-lipgloss.Width(right)-1, 1) + + var left string + switch { + case m.message != "": + left = t.messageLine(m.message, m.messageOK, room) + case !m.noHints: + left = t.hintsLine(m.keyHints(), room) + } + gap := max(width-lipgloss.Width(left)-lipgloss.Width(right), 1) + return left + strings.Repeat(" ", gap) + right +} + +// keyHints lists the keys that run an Action right now, each with its +// name, as (key, name) pairs in the order the Actions came in. Unbound +// Actions are left out, and so are the printable keys the filter has +// taken for typing. +func (m Model) keyHints() [][2]string { + var out [][2]string + for _, a := range m.hints { + if _, ok := m.boundAction(a.Key); ok { + out = append(out, [2]string{a.Key, a.Name}) + } + } + return out +} + +// messageLine renders a one-line message in green when it is good news and +// red when it is an error, cut to width. +func (t theme) messageLine(text string, ok bool, width int) string { + colour := t.red + if ok { + colour = t.green + } + return t.fg(colour).Render(truncateName(text, width)) +} + +// hintsLine renders (key, name) pairs like keysLine: as many whole pairs as +// fit in width, followed by " …" when some are dropped. When not even the +// first fits, it is cut to width instead. +func (t theme) hintsLine(items [][2]string, width int) string { + if len(items) == 0 { + return "" + } + widthOf := func(n int) int { + w := 0 + for i, it := range items[:n] { + w += lipgloss.Width(it[0] + " " + it[1]) + if i > 0 { + w += hintSepWidth + } + } + return w + } + + n := len(items) + for n > 0 && widthOf(n) > width { + n-- + } + if n < len(items) { + // Dropping any leaves room to say so. + for n > 0 && widthOf(n)+2 > width { + n-- + } + } + if n == 0 { + return t.muted_().Render(truncateName(items[0][0]+" "+items[0][1], width)) + } + + flat := make([]string, 0, 2*n) + for _, it := range items[:n] { + flat = append(flat, it[0], it[1]) + } + line := t.keysLine(flat...) + if n < len(items) { + line += t.muted_().Render(" …") + } + return line +} diff --git a/internal/picker/view_footer_test.go b/internal/picker/view_footer_test.go new file mode 100644 index 0000000..02115c9 --- /dev/null +++ b/internal/picker/view_footer_test.go @@ -0,0 +1,129 @@ +package picker_test + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/kryft-dev/cdd/internal/action" + "github.com/kryft-dev/cdd/internal/picker" +) + +var errBoom = errors.New("boom") + +// footerOf is a sized Model's last line: the keys line, with the match +// count on its right. +func footerOf(m picker.Model) string { + lines := strings.Split(plain(m.View().Content), "\n") + return lines[len(lines)-1] +} + +// sizedActions is a Model with actions bound, sized width by 40. +func sizedActions(width int, vim bool, actions ...action.Action) picker.Model { + m := twoRowModel(picker.Options{Vim: vim, Actions: actions}) + next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: 40}) + next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + return next.(picker.Model) +} + +func TestFooter_WideListsEveryBoundActionBuiltinsInTheirOrder(t *testing.T) { + m := sizedActions(120, false, action.Builtins()...) + + line := footerOf(m) + + const want = "enter jump · ctrl+o files · ctrl+e editor · ctrl+g remote · ctrl+y copy" + if !strings.HasPrefix(line, want) { + t.Errorf("footer = %q, want it to start %q", line, want) + } + if strings.Contains(line, "…") || !strings.HasSuffix(line, "2/2") { + t.Errorf("footer = %q, want nothing dropped and the count at the right edge", line) + } + if w := lipgloss.Width(line); w != 120 { + t.Errorf("footer is %d wide, want 120", w) + } +} + +func TestFooter_NarrowDropsWholeHintsBehindAnEllipsis(t *testing.T) { + m := sizedActions(40, false, action.Builtins()...) + + line := footerOf(m) + + if !strings.HasPrefix(line, "enter jump · ctrl+o files …") { + t.Errorf("footer = %q, want the first hints then an ellipsis", line) + } + if strings.Contains(line, "ctrl+e") { + t.Errorf("footer = %q, want ctrl+e dropped whole", line) + } + if w := lipgloss.Width(line); w != 40 { + t.Errorf("footer is %d wide, want 40", w) + } +} + +func TestFooter_NeverWiderThanTheTerminal(t *testing.T) { + for width := 5; width <= 80; width++ { + line := footerOf(sizedActions(width, false, action.Builtins()...)) + if w := lipgloss.Width(line); w > width { + t.Fatalf("footer at width %d is %d wide: %q", width, w, line) + } + } +} + +func TestFooter_HintOrderIsEnterFirstThenTheOrderGiven(t *testing.T) { + code := action.Action{Name: "code", Key: "enter", Run: "code {path}", Detach: true} + lazy := action.Action{Name: "lazygit", Key: "ctrl+l", Run: "lazygit"} + moved := action.Action{Name: "jump", Key: "alt+enter", Jump: true} + unbound := action.Action{Name: "off", Key: "", Run: "x"} + m := sizedActions(120, false, lazy, moved, unbound, code) + + line := footerOf(m) + + if want := "enter code · ctrl+l lazygit · alt+enter jump"; !strings.HasPrefix(line, want) { + t.Errorf("footer = %q, want it to start %q", line, want) + } + if strings.Contains(line, "off") { + t.Errorf("footer = %q lists the unbound Action", line) + } +} + +func TestFooter_NoActionsNoHints(t *testing.T) { + line := footerOf(sizedActions(80, false)) + + if got := strings.TrimSpace(line); got != "2/2" { + t.Errorf("footer = %q, want only the count", line) + } +} + +func TestFooter_MessageReplacesHintsUntilTheNextKey(t *testing.T) { + r := &fakeRunner{err: errBoom} + web := action.Action{Name: "web", Key: "ctrl+w", Run: "open", Detach: true} + m := actionModel(r, false, jump, web) + + m, _ = press(m, tea.KeyPressMsg{Code: 'w', Mod: tea.ModCtrl}) + line := footerOf(m) + if !strings.HasPrefix(line, "web: ") || strings.Contains(line, "enter jump") || !strings.HasSuffix(line, "2/2") { + t.Errorf("footer = %q, want the message in place of the hints, then the count", line) + } + + m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if line := footerOf(m); !strings.HasPrefix(line, "enter jump") { + t.Errorf("footer = %q, want the hints back after a key press", line) + } +} + +func TestFooter_ErrorAndConfirmationAreDrawnInDifferentColours(t *testing.T) { + m := actionModel(&fakeRunner{err: errBoom}, false, jump, action.Action{Name: "web", Key: "ctrl+w", Run: "open", Detach: true}) + bad, _ := press(m, tea.KeyPressMsg{Code: 'w', Mod: tea.ModCtrl}) + + good, _ := press(copyModel(func(string) (bool, error) { return true, nil }), ctrlY) + + last := func(m picker.Model) string { + lines := strings.Split(m.View().Content, "\n") + return lines[len(lines)-1] + } + if last(bad)[:20] == last(good)[:20] { + t.Errorf("error and confirmation share a style:\n%q\n%q", last(bad), last(good)) + } +} diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go index 5b37363..6112e13 100644 --- a/internal/picker/view_test.go +++ b/internal/picker/view_test.go @@ -9,6 +9,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/kryft-dev/cdd/internal/action" "github.com/kryft-dev/cdd/internal/picker" ) @@ -22,7 +23,7 @@ var allLayouts = []picker.Layout{picker.LayoutList} // terminal with a settled palette is the state every View test starts // from, since nothing is drawn before the palette settles. func sizedModel(rows []picker.Row, layout picker.Layout, width, height int) picker.Model { - m := picker.NewModel(rows, noopStatus, picker.Options{Layout: layout}) + m := picker.NewModel(rows, noopStatus, picker.Options{Layout: layout, Actions: action.Builtins()}) next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: height}) next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) return next.(picker.Model) @@ -72,7 +73,7 @@ func TestModel_View_ListWindowedToHeight(t *testing.T) { sawKeys := false for _, l := range lines { - if strings.Contains(l, "move") && strings.Contains(l, "jump") { + if strings.Contains(plain(l), "enter jump") { sawKeys = true } } From 3992a055d4d1fcc79cfae7c9110c85970b4c6a8b Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:43:31 +0500 Subject: [PATCH 4/6] feat(picker): [picker] hints = false hides the key hints (#67) --- internal/jump/action_test.go | 17 +++++++++++++++++ internal/jump/jump.go | 9 +++++---- internal/picker/view_footer_test.go | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/jump/action_test.go b/internal/jump/action_test.go index 382a2b7..e8ec371 100644 --- a/internal/jump/action_test.go +++ b/internal/jump/action_test.go @@ -164,3 +164,20 @@ func TestResolve_PickerGetsTheResolvedActionsAndARecordingRunner(t *testing.T) { t.Errorf("Visits = %d, want %d after a failed start", n, before) } } + +func TestResolve_PickerHidesHintsWhenConfigSaysSo(t *testing.T) { + for _, hints := range []bool{true, false} { + hist := newHistory(t) + cfg, root := mkProjects(t, hist, "tools/cdd") + path := filepath.Join(root, "tools", "cdd") + cfg.Picker.Hints = hints + + var opts picker.Options + if _, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), &fakeRunner{}); err != nil { + t.Fatalf("Resolve: %v", err) + } + if opts.HideHints == hints { + t.Errorf("Picker.Hints = %v: Options.HideHints = %v", hints, opts.HideHints) + } + } +} diff --git a/internal/jump/jump.go b/internal/jump/jump.go index 551457e..587a068 100644 --- a/internal/jump/jump.go +++ b/internal/jump/jump.go @@ -130,10 +130,11 @@ func choose(cfg config.Config, latest []history.Visit, counts map[string]int, pi } opts := picker.Options{ - Vim: cfg.Keys.Vim, - Layout: picker.Layout(cfg.Picker.Layout), - Actions: cfg.ResolvedActions, - Runner: run, + Vim: cfg.Keys.Vim, + Layout: picker.Layout(cfg.Picker.Layout), + Actions: cfg.ResolvedActions, + HideHints: !cfg.Picker.Hints, + Runner: run, } choice, ok, err := pick(rows, status, opts) if err != nil { diff --git a/internal/picker/view_footer_test.go b/internal/picker/view_footer_test.go index 02115c9..0f20367 100644 --- a/internal/picker/view_footer_test.go +++ b/internal/picker/view_footer_test.go @@ -127,3 +127,21 @@ func TestFooter_ErrorAndConfirmationAreDrawnInDifferentColours(t *testing.T) { t.Errorf("error and confirmation share a style:\n%q\n%q", last(bad), last(good)) } } + +func TestFooter_HideHintsKeepsTheCountAndMessages(t *testing.T) { + r := &fakeRunner{err: errBoom} + web := action.Action{Name: "web", Key: "ctrl+w", Run: "open", Detach: true} + m := twoRowModel(picker.Options{Actions: []action.Action{jump, web}, Runner: r, HideHints: true}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + m = next.(picker.Model) + + if got := strings.TrimSpace(footerOf(m)); got != "2/2" { + t.Errorf("footer = %q, want only the count", got) + } + + m, _ = press(m, tea.KeyPressMsg{Code: 'w', Mod: tea.ModCtrl}) + if line := footerOf(m); !strings.HasPrefix(line, "web: ") { + t.Errorf("footer = %q, want the failure still shown", line) + } +} From 3b254e2d655551a383cd9e13869f2a476250b4af Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:44:15 +0500 Subject: [PATCH 5/6] feat(picker): vim ? opens an overlay of every key (#67) --- internal/picker/model.go | 1 + internal/picker/update.go | 19 +++++ internal/picker/view_footer.go | 6 +- internal/picker/view_help.go | 72 +++++++++++++++++ internal/picker/view_help_test.go | 127 ++++++++++++++++++++++++++++++ internal/picker/view_list.go | 3 + 6 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 internal/picker/view_help.go create mode 100644 internal/picker/view_help_test.go diff --git a/internal/picker/model.go b/internal/picker/model.go index 400d809..acfb690 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -40,6 +40,7 @@ type Model struct { actions map[string]action.Action // keyed by Action.Key hints []action.Action // the bound Actions in hint order noHints bool // Options.HideHints + help bool // the vim help overlay is open runner action.Runner copy func(text string) (bool, error) diff --git a/internal/picker/update.go b/internal/picker/update.go index 94775fa..ea578b4 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -44,6 +44,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // the same name. func (m Model) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.message, m.messageOK = "", false + if m.help { + return m.updateKeyHelp(msg) + } if a, ok := m.boundAction(msg.String()); ok { return m.runAction(a) } @@ -119,12 +122,28 @@ func (m Model) updateKeyVim(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "f", "/": m.focus = focusFilter return m, nil + case "?": + m.help = true + return m, nil case "esc", "q", "ctrl+c": return m.cancel() } return m, nil } +// updateKeyHelp handles a key press while the vim help overlay is open: ?, +// esc or q close it, ctrl+c still cancels, and nothing else does anything, +// so an Action key cannot fire unseen behind the overlay. +func (m Model) updateKeyHelp(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "?", "esc", "q": + m.help = false + case "ctrl+c": + return m.cancel() + } + return m, nil +} + // boundAction returns the Action bound to key. A plain printable key only // counts in the vim key map's list focus, elsewhere it is typing. func (m Model) boundAction(key string) (action.Action, bool) { diff --git a/internal/picker/view_footer.go b/internal/picker/view_footer.go index 0f67bc9..f2bf8e7 100644 --- a/internal/picker/view_footer.go +++ b/internal/picker/view_footer.go @@ -25,10 +25,14 @@ func (m Model) footerView(t theme, width, matched int, met Metrics) string { } // keysLine is the footer's one line: on the left the message, else the key -// hints, and on the right the match count. A message replaces the hints +// hints, and on the right the match count, with a pointer to the help +// overlay ahead of it where `?` opens one. A message replaces the hints // until the next key press. func (m Model) keysLine(t theme, width, matched int) string { right := t.muted_().Render(fmt.Sprintf("%d/%d", matched, len(m.rows))) + if m.helpAvailable() && !m.noHints { + right = t.keysLine("?", "help") + " " + right + } room := max(width-lipgloss.Width(right)-1, 1) var left string diff --git a/internal/picker/view_help.go b/internal/picker/view_help.go new file mode 100644 index 0000000..59e0686 --- /dev/null +++ b/internal/picker/view_help.go @@ -0,0 +1,72 @@ +package picker + +import ( + "charm.land/lipgloss/v2" + + "github.com/kryft-dev/cdd/internal/action" +) + +// vimKeys are the vim key map's own keys with what they do, for the help +// overlay. +var vimKeys = [][2]string{ + {"j / k", "move down / up"}, + {"g / G", "first / last row"}, + {"f or /", "focus the filter"}, + {"esc (filter)", "back to the list, keeping the query"}, + {"esc or q", "cancel"}, + {"?", "close this help"}, +} + +// helpAvailable reports whether `?` opens the help overlay: in the vim key +// map's list focus, unless an Action has taken the key. +func (m Model) helpAvailable() bool { + _, taken := m.actions["?"] + return m.vim && m.focus == focusList && !taken +} + +// helpView renders the overlay of every key, navigation and Actions, in +// place of the whole frame. It is clipped to height, so a short terminal +// loses the end of the list and never the line that says how to close it. +func (m Model) helpView(t theme, width, height int) string { + keyCol := 0 + for _, kv := range vimKeys { + keyCol = max(keyCol, lipgloss.Width(kv[0])) + } + for _, a := range m.hints { + keyCol = max(keyCol, lipgloss.Width(a.Key)) + } + row := func(key, desc string) string { + key = t.fg(t.muted).Bold(true).Render(key) + desc = t.muted_().Render(truncateName(desc, max(width-keyCol-2, 1))) + return padRightOn(plainStyle, key, keyCol) + " " + desc + } + + lines := []string{ + t.accentBold().Render("keys") + t.muted_().Render(" ? closes this help"), + "", + t.muted_().Render("navigation"), + } + for _, kv := range vimKeys { + lines = append(lines, row(kv[0], kv[1])) + } + if len(m.hints) > 0 { + lines = append(lines, "", t.muted_().Render("actions")) + } + for _, a := range m.hints { + lines = append(lines, row(a.Key, a.Name+" "+describe(a))) + } + return window(lines, 0, height) +} + +// describe says in a few words what running a does. +func describe(a action.Action) string { + switch { + case a.Copy: + return "copies the path" + case a.Run != "": + return a.Run + case a.Jump: + return "jumps to the Project" + } + return "" +} diff --git a/internal/picker/view_help_test.go b/internal/picker/view_help_test.go new file mode 100644 index 0000000..cadae17 --- /dev/null +++ b/internal/picker/view_help_test.go @@ -0,0 +1,127 @@ +package picker_test + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/kryft-dev/cdd/internal/action" + "github.com/kryft-dev/cdd/internal/picker" +) + +var ( + question = tea.KeyPressMsg{Code: '?', Text: "?"} + escape = tea.KeyPressMsg{Code: tea.KeyEscape} +) + +func TestHelp_VimQuestionMarkOpensAndClosesTheOverlay(t *testing.T) { + m := actionModel(&fakeRunner{}, true, action.Builtins()...) + + m, _ = press(m, question) + open := plain(m.View().Content) + for _, want := range []string{"j / k", "move down / up", "esc or q", "cancel", "actions", "ctrl+o", "files", "ctrl+e"} { + if !strings.Contains(open, want) { + t.Errorf("overlay lacks %q:\n%s", want, open) + } + } + if strings.Contains(open, "alpha") { + t.Errorf("overlay still shows the list:\n%s", open) + } + + m, _ = press(m, question) + if closed := plain(m.View().Content); !strings.Contains(closed, "alpha") || strings.Contains(closed, "navigation") { + t.Errorf("? did not close the overlay:\n%s", closed) + } +} + +func TestHelp_EscAndQCloseItInsteadOfCancelling(t *testing.T) { + for _, key := range []tea.KeyPressMsg{escape, {Code: 'q', Text: "q"}} { + m := actionModel(&fakeRunner{}, true, action.Builtins()...) + m, _ = press(m, question) + + m, cmd := press(m, key) + + if cmd != nil || strings.Contains(plain(m.View().Content), "navigation") { + t.Errorf("%v: Cmd = %v, want the overlay closed and the Picker open", key, cmd != nil) + } + } +} + +func TestHelp_SwallowsActionKeysButCtrlCStillCancels(t *testing.T) { + m := actionModel(&fakeRunner{}, true, action.Builtins()...) + m, _ = press(m, question) + + m, cmd := press(m, enter) + if _, chosen := m.Chosen(); chosen || cmd != nil { + t.Fatalf("enter under the overlay chose a Project") + } + if !strings.Contains(plain(m.View().Content), "navigation") { + t.Errorf("a stray key closed the overlay") + } + + _, cmd = press(m, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if cmd == nil { + t.Errorf("ctrl+c under the overlay did not quit") + } +} + +func TestHelp_OnlyInVimListFocus(t *testing.T) { + // The default key map: ? is typing. + m := actionModel(&fakeRunner{}, false, action.Builtins()...) + m, _ = press(m, question) + if v := plain(m.View().Content); strings.Contains(v, "navigation") || !strings.Contains(v, "❯ ?") { + t.Errorf("default map: ? should type into the filter:\n%s", v) + } + + // The vim filter focus: ? is typing too. + m = actionModel(&fakeRunner{}, true, action.Builtins()...) + m, _ = press(m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + m, _ = press(m, question) + if v := plain(m.View().Content); strings.Contains(v, "navigation") || !strings.Contains(v, "❯ ?") { + t.Errorf("vim filter focus: ? should type into the filter:\n%s", v) + } +} + +func TestHelp_AnActionOnQuestionMarkWinsTheKey(t *testing.T) { + r := &fakeRunner{} + m := actionModel(r, true, jump, action.Action{Name: "web", Key: "?", Run: "open", Detach: true}) + + m, _ = press(m, question) + + if len(r.started) != 1 || strings.Contains(plain(m.View().Content), "navigation") { + t.Errorf("started = %v, want the Action run and no overlay", r.started) + } +} + +func TestHelp_FooterPointsToItOnlyWhereItOpens(t *testing.T) { + if line := footerOf(sizedActions(120, true, action.Builtins()...)); !strings.Contains(line, "? help") { + t.Errorf("vim footer = %q, want the ? help pointer", line) + } + if line := footerOf(sizedActions(120, false, action.Builtins()...)); strings.Contains(line, "? help") { + t.Errorf("default footer = %q, want no pointer without an overlay", line) + } +} + +func TestHelp_FitsTheTerminalHeightAndKeepsTheCloseLine(t *testing.T) { + for _, height := range []int{40, 20, 9, 3} { + m := picker.NewModel(manyRows(5), noopStatus, picker.Options{Vim: true, Actions: action.Builtins()}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: height}) + next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + next, _ = next.(picker.Model).Update(question) + + lines := strings.Split(next.(picker.Model).View().Content, "\n") + if len(lines) != height { + t.Errorf("overlay at height %d is %d lines", height, len(lines)) + } + if !strings.Contains(plain(lines[0]), "? closes this help") { + t.Errorf("height %d: first line = %q, want the close hint", height, lines[0]) + } + for _, l := range lines { + if w := lipgloss.Width(l); w > 60 { + t.Errorf("height %d: line %q is %d wide", height, l, w) + } + } + } +} diff --git a/internal/picker/view_list.go b/internal/picker/view_list.go index b5b863d..ba69ced 100644 --- a/internal/picker/view_list.go +++ b/internal/picker/view_list.go @@ -16,6 +16,9 @@ const selBar = "▌" func (m Model) listFrame(t theme, now time.Time) string { rows := m.visibleRows() width, height := m.frameSize() + if m.help { + return m.helpView(t, width, height) + } met := m.computeMetrics(rows, now, width, height) var b strings.Builder From 166ea42e1d0fd6f5aea03539de0c68908dc62e4f Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:44:22 +0500 Subject: [PATCH 6/6] docs: document the key hints, the vim help overlay and [picker] hints (#67) --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 7f20376..ee2d63b 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,23 @@ Vim key map (`keys.vim = true`): the list is focused on open. | `esc` (filter) | return to the list, keeping the query | | `esc` / `q` | cancel | | `enter` | Jump to the selected Project (the `jump` Action) | +| `?` | open an overlay of every key, including your Actions' (list focus), and `?`, `esc` or `q` to close it | + +### Key hints + +The last line of the Picker lists the keys of your Actions, e.g. +`enter jump · ctrl+o files · ctrl+e editor · …`, with the match count at its +right edge. Whatever holds `enter` comes first, then your own `[actions.*]` +in the order `config.toml` declares them, then the built-ins; an Action with +`key = ""` is not listed. A line too narrow for all of them drops whole hints +from the end behind a `…`. A failure or confirmation (such as `copied`) +replaces the hints until the next key press. + +With `keys.vim = true` the line also points to `?`, which opens an overlay of +every key, navigation included, and `?`, `esc` or `q` closes it. An Action +you bind to `?` takes the key from the overlay, and the default key map has +no overlay, since `?` is typing there. Set `[picker] hints = false` to hide +the hints, leaving the count and messages. ### Actions @@ -251,6 +268,7 @@ max_visits = 1000 vim = false [picker] layout = "list" +hints = true ``` - `exclude`: glob patterns (`filepath.Match` semantics) for directories @@ -271,6 +289,8 @@ layout = "list" - `[picker].layout`: which Layout the Picker draws. `"list"`, described above, is the default and, for now, the only one. Any other value is a config error. +- `[picker].hints`: when `true` (the default), the last line lists the keys + of your Actions; see [Key hints](#key-hints). `false` hides them. Upgrading from v0.2: `root` and the `"grouped"` layout are gone. Delete them from `config.toml` (cdd names the offending line), then run `cdd scan`