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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ Everything boardwalk needs to know lives in one file at
| `org` | the Azure DevOps organisation — the first path segment of `https://dev.azure.com/{org}` |
| `project` | the team project within it |
| `reviewGroups` | optional — extra teams and security groups to treat as yours, named as Azure DevOps displays them |
| `paletteKey` | optional — the key that opens the [command palette](#command-palette), spelled `ctrl+k`, `:`, `f1`; defaults to `ctrl+p` |

Azure DevOps scopes a group's display name to wherever it lives —
`[TEAM FOUNDATION]\platform-devs` for a collection group, `[MyProject]\Team
Expand Down Expand Up @@ -136,6 +137,27 @@ Every view fetches when it is opened, so the menu paints immediately and a
fetch that fails lands on the status line with `r` to try again, rather than
taking the program down with it.

### Command palette

`^p` opens a searchable list of everything the current screen can do. Type to
fuzzy-filter it, `↑`/`↓` (or `^j`/`^k`) to move, `enter` to run,
`esc` or the palette key again to close. Each entry shows the key it stands for, so the palette doubles
as a way to learn them.

- **Actions** are the same bindings the `?` panel lists, and running one does
exactly what pressing its key does — the two cannot disagree.
- **go to work items / pull requests / builds** is offered from anywhere. If
that view is already underneath, boardwalk returns to it as you left it;
otherwise it opens fresh.
- **Recent** entries come first. They are remembered beside the config, in
`~/.config/boardwalk.history.json`, so each config keeps its own.

The key is `paletteKey` in the config. boardwalk refuses a key it does not
recognise, or one it needs itself (`esc`, `q`, `?`, `enter`, `^c`, and the
menu's `↑`/`↓`/`j`/`k`), at startup. A key a view also binds is taken by the
palette on that view, so pick one that is free. Like every other key, it is left to a
prompt while one is open.

### Every list view

Work items, pull requests and builds share these. The log pane is a pager
Expand Down
33 changes: 33 additions & 0 deletions docs/superpowers/plans/2026-09-26-command-palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Command Palette Implementation Plan

> **For agentic workers:** Use superpowers:executing-plans. Steps use checkbox (`- [x]`) syntax.

**Goal:** A configurable-key command palette listing every action on the current screen, with fuzzy filtering, persisted recents, and "go to" entries for the three main views.

**Spec:** `docs/superpowers/specs/2026-09-26-command-palette-design.md`

## Global Constraints

- No new third-party dependencies. Fuzzy matching is `bubbles/list.DefaultFilter`.
- No view changes: actions are replayed as key messages.
- Test-first; `make test` and `make vet` clean before each commit.

### Task 1: key string → tea.KeyMsg
- [x] Test: every binding key in the package round-trips through `keyMsgFor(...).String()`; unknown names fail.
- [x] Implement `internal/ui/keymsg.go` with a lazily built reverse table.

### Task 2: history store
- [x] Test: record moves to front, dedupes, caps, persists across `Open`, tolerates a missing/corrupt file, memory-only with empty path.
- [x] Implement `internal/history/history.go`.

### Task 3: palette model
- [x] Test: empty query orders recent → go to → actions; query fuzzy-filters; cursor clamps; enter chooses; esc closes; toggle key closes unless printable.
- [x] Implement `internal/ui/palette.go`.

### Task 4: Root integration
- [x] Test: palette key opens from menu and from a view; not while prompting; running an action replays its key; go-to cuts back or rebuilds; history recorded; help panel lists the palette key.
- [x] Implement options `WithPaletteKey`, `WithHistory`, `goToMsg`, rendering.

### Task 5: config and wiring
- [x] Test: `paletteKey` parses; default `ctrl+p`; `HistoryPath` beside config; invalid key rejected.
- [x] Wire in `main.go`; document in README.
89 changes: 89 additions & 0 deletions docs/superpowers/specs/2026-09-26-command-palette-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# boardwalk: command palette

A fuzzy-searchable list of everything the current screen can do, opened with
one configurable key, so nobody has to remember forty bindings to use them.

## Goals

- One key (default `ctrl+p`, configurable) opens the palette from the menu or
any view.
- The palette lists every action the screen on top binds, named by its help
text and showing the key it stands for, so using the palette teaches the key.
- A fuzzy filter narrows the list as you type; matched characters are
highlighted.
- Recently run entries are listed first, and survive a restart.
- "go to work items / pull requests / builds" is always offered, from any depth.
- The key bindings and the `?` panel stay exactly as they are.

## Non-goals

- Arguments to actions (a palette entry runs exactly what its key would).
- A second, palette-only set of actions. If a key does not exist, neither does
the entry.

## Design

### Actions come from the bindings

Every view already returns its bindings from `Keys()`, and the `?` panel is
`FullHelp()` of that map. The palette flattens the same groups, so an action
is in the palette if and only if it is in the panel — the two cannot drift.

Running an entry synthesises the `tea.KeyMsg` of the binding's first key and
feeds it back through `Root.Update`. The view cannot tell the difference, so no
view changes and no action is implemented twice. Bindings are deduplicated by
description, and `?` (the panel) is left out; a disabled binding is skipped.

`keyMsgFor` turns a binding's key string back into a `tea.KeyMsg`. bubbletea
keeps its name table private, so the reverse table is built once, lazily, by
asking every `KeyType` for its name.

### Navigation

A "go to" entry is a `goToMsg{name}`. If the stack's root is already that
view, the stack is cut back to it, keeping its loaded rows and cursor;
otherwise the stack is replaced with a freshly built view.

### History

`Root` depends on a two-method `History` interface (`Recent`, `Record`).
`internal/history.Store` implements it as a most-recent-first, capped, JSON
file beside the config: `~/.config/boardwalk.json` keeps its history in
`~/.config/boardwalk.history.json`. A second config therefore has its own
history. A failed read or write is ignored — history is a convenience, and
must never cost the user the action they asked for.

Entry ids are `nav:<view>` and `act:<description>`. Recent entries are those
history ids present on the current screen, so a recent action from another
view does not appear where it would do nothing.

### Ranking

An empty query lists recent entries, then "go to", then actions. A query is
ranked by `list.DefaultFilter` — the same fuzzy matcher the list views' `/`
filter uses, so no new dependency — and the sort is stable, so recent entries
win ties.

### Keys inside the palette

`↑`/`↓`, `ctrl+j`/`ctrl+k` and `ctrl+n`/`ctrl+p` move, `enter` runs, `esc`
closes, and the palette key itself closes it unless it is a printable
character (which is typed into the query instead). `ctrl+c` still quits.
While a view has a prompt open the palette key is left to the prompt, like
every other Root key.

### Configuration

```json
{ "paletteKey": "ctrl+k" }
```

Validated at startup: an unknown key name, or one of the keys Root cannot give
up (`esc`, `q`, `?`, `ctrl+c`), is an error naming the file.

### Files

Added: `internal/ui/palette.go`, `internal/ui/keymsg.go`,
`internal/history/history.go`, with tests.
Changed: `internal/ui/root.go`, `internal/config/config.go`, `main.go`,
`README.md`.
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
)

// EnvPath overrides where the config is read from, for keeping more than one —
Expand Down Expand Up @@ -41,6 +42,10 @@ type Config struct {
// offline; the price is the one it always had, that it goes stale when
// your memberships change.
ReviewGroups []string `json:"reviewGroups"`

// PaletteKey opens the command palette, spelled the way bubbletea names
// keys: "ctrl+p", "ctrl+k", ":". Empty means the default, ctrl+p.
PaletteKey string `json:"paletteKey,omitempty"`
}

// ErrMissing is returned when there is no config file at all, so a caller can
Expand Down Expand Up @@ -91,6 +96,16 @@ func Path() (string, error) {
return filepath.Join(home, ".config", "boardwalk.json"), nil
}

// HistoryPath is where the command palette remembers what it ran: beside the
// config, named after it, so a second config keeps a history of its own.
func HistoryPath() (string, error) {
path, err := Path()
if err != nil {
return "", err
}
return strings.TrimSuffix(path, filepath.Ext(path)) + ".history.json", nil
}

// Validate reports what is missing, naming the file so the message says where
// to fix it rather than only what is wrong.
func (c Config) Validate(path string) error {
Expand Down
21 changes: 20 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ func TestLoadReadsEverySetting(t *testing.T) {
t.Setenv(EnvPath, writeConfig(t, `{
"org": "acme",
"project": "Platform",
"reviewGroups": ["platform-devs", "Platform Leads"]
"reviewGroups": ["platform-devs", "Platform Leads"],
"paletteKey": "ctrl+k"
}`))

c, err := Load()
Expand All @@ -34,6 +35,24 @@ func TestLoadReadsEverySetting(t *testing.T) {
if len(c.ReviewGroups) != 2 || c.ReviewGroups[0] != "platform-devs" {
t.Errorf("groups = %v", c.ReviewGroups)
}
if c.PaletteKey != "ctrl+k" {
t.Errorf("paletteKey = %q", c.PaletteKey)
}
}

func TestHistoryPathSitsBesideTheConfig(t *testing.T) {
// Each config keeps its own history, so a second organisation's recent
// actions do not crowd out the first's.
for cfg, want := range map[string]string{
"/home/me/.config/boardwalk.json": "/home/me/.config/boardwalk.history.json",
"/home/me/.config/boardwalk.other.json": "/home/me/.config/boardwalk.other.history.json",
"/etc/boardwalk": "/etc/boardwalk.history.json",
} {
t.Setenv(EnvPath, cfg)
if got, err := HistoryPath(); err != nil || got != want {
t.Errorf("HistoryPath() for %s = %q, %v; want %q", cfg, got, err, want)
}
}
}

func TestLoadDistinguishesAMissingConfigFromABrokenOne(t *testing.T) {
Expand Down
80 changes: 80 additions & 0 deletions internal/history/history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Package history remembers what the command palette ran, most recent first,
// so the actions someone reaches for most are the first ones offered.
package history

import (
"encoding/json"
"os"
"path/filepath"
"slices"
)

// Store is a capped, most-recent-first list of ids, kept in a JSON file.
//
// It is not safe for concurrent use; the palette only touches it from the
// bubbletea update loop.
type Store struct {
path string
limit int
ids []string
}

// Open reads the history at path. A missing or unreadable file is an empty
// history rather than an error: history is a convenience, and losing it must
// never stop boardwalk starting. An empty path keeps history in memory only.
func Open(path string, limit int) *Store {
s := &Store{path: path, limit: max(1, limit)}
if path == "" {
return s
}
if body, err := os.ReadFile(path); err == nil {
var ids []string
if json.Unmarshal(body, &ids) == nil {
s.ids = ids[:min(len(ids), s.limit)]
}
}
return s
}

// Recent is every remembered id, most recent first. The slice is the store's
// own and must not be modified; handing it out uncopied saves an allocation
// on every palette open.
func (s *Store) Recent() []string { return s.ids }

// Record moves id to the front, dropping the oldest past the limit, and
// writes the file. The in-memory list is updated even when the write fails,
// so the session still sees it.
func (s *Store) Record(id string) error {
if i := slices.Index(s.ids, id); i >= 0 {
// Shift the ones before it down one, in place.
copy(s.ids[1:i+1], s.ids[:i])
s.ids[0] = id
} else {
if len(s.ids) < s.limit {
s.ids = append(s.ids, "")
}
copy(s.ids[1:], s.ids)
s.ids[0] = id
}
return s.save()
}

// save writes through a temporary file and a rename, so a crash mid-write
// leaves the old history rather than half of a new one.
func (s *Store) save() error {
if s.path == "" {
return nil
}
body, err := json.Marshal(s.ids)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
70 changes: 70 additions & 0 deletions internal/history/history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package history

import (
"os"
"path/filepath"
"slices"
"testing"
)

func TestRecordPutsTheLatestFirstWithoutDuplicates(t *testing.T) {
s := Open("", 10)
for _, id := range []string{"a", "b", "a", "c"} {
s.Record(id)
}
if got, want := s.Recent(), []string{"c", "a", "b"}; !slices.Equal(got, want) {
t.Errorf("Recent() = %v, want %v", got, want)
}
}

func TestRecordDropsTheOldestPastTheLimit(t *testing.T) {
s := Open("", 2)
for _, id := range []string{"a", "b", "c"} {
s.Record(id)
}
if got, want := s.Recent(), []string{"c", "b"}; !slices.Equal(got, want) {
t.Errorf("Recent() = %v, want %v", got, want)
}
}

func TestHistorySurvivesAReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "sub", "history.json")
s := Open(path, 10)
if err := s.Record("nav:prs"); err != nil {
t.Fatalf("Record: %v", err)
}
s.Record("act:refresh")

if got, want := Open(path, 10).Recent(), []string{"act:refresh", "nav:prs"}; !slices.Equal(got, want) {
t.Errorf("reopened Recent() = %v, want %v", got, want)
}
}

func TestOpenTreatsAMissingOrCorruptFileAsEmpty(t *testing.T) {
dir := t.TempDir()
if got := Open(filepath.Join(dir, "none.json"), 10).Recent(); len(got) != 0 {
t.Errorf("missing file: Recent() = %v", got)
}

bad := filepath.Join(dir, "bad.json")
os.WriteFile(bad, []byte("{not json"), 0o600)
s := Open(bad, 10)
if got := s.Recent(); len(got) != 0 {
t.Errorf("corrupt file: Recent() = %v", got)
}
// A corrupt file is replaced on the next write rather than wedging history.
if err := s.Record("x"); err != nil {
t.Fatalf("Record over a corrupt file: %v", err)
}
if got := Open(bad, 10).Recent(); !slices.Equal(got, []string{"x"}) {
t.Errorf("after rewrite Recent() = %v", got)
}
}

func TestOpenTrimsAFileLongerThanTheLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "h.json")
os.WriteFile(path, []byte(`["a","b","c"]`), 0o600)
if got := Open(path, 2).Recent(); !slices.Equal(got, []string{"a", "b"}) {
t.Errorf("Recent() = %v", got)
}
}
Loading
Loading