diff --git a/cmd/entire/cli/activity_tui.go b/cmd/entire/cli/activity_tui.go index 160ec93538..72f2be204e 100644 --- a/cmd/entire/cli/activity_tui.go +++ b/cmd/entire/cli/activity_tui.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -113,7 +114,7 @@ func (m activityModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:iretu return m, nil case tea.KeyMsg: - if msg.Type == tea.KeyEscape || msg.Type == tea.KeyCtrlC || msg.String() == "q" { + if key.Matches(msg, keys.Quit) || key.Matches(msg, keys.Back) { return m, tea.Quit } @@ -222,7 +223,7 @@ func (m activityModel) renderFooter() string { } return keyStyle.Render("↑↓") + helpStyle.Render(" scroll") + - sep + keyStyle.Render("q") + helpStyle.Render(" quit") + + sep + keyStyle.Render(keys.Quit.Help().Key) + helpStyle.Render(" "+keys.Quit.Help().Desc) + scrollPct } diff --git a/cmd/entire/cli/dispatch_tui.go b/cmd/entire/cli/dispatch_tui.go index b2773efc17..72c87d4f6d 100644 --- a/cmd/entire/cli/dispatch_tui.go +++ b/cmd/entire/cli/dispatch_tui.go @@ -7,6 +7,7 @@ import ( "io" "strings" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" @@ -60,8 +61,6 @@ var newDispatchProgram = func(model tea.Model, outW io.Writer, altScreen bool) d return tea.NewProgram(model, options...) } -const tuiEscKey = "esc" - func defaultRunInteractiveDispatch(ctx context.Context, outW io.Writer, opts dispatchpkg.Options) (string, error) { runCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -381,8 +380,7 @@ func (m dispatchStatusModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.result = msg return m, tea.Quit case tea.KeyMsg: - switch msg.String() { - case tea.KeyCtrlC.String(), tuiEscKey, "q": + if key.Matches(msg, keys.Quit) || key.Matches(msg, keys.Back) { if m.cancel != nil { m.cancel() } diff --git a/cmd/entire/cli/keys.go b/cmd/entire/cli/keys.go new file mode 100644 index 0000000000..c8d11ad106 --- /dev/null +++ b/cmd/entire/cli/keys.go @@ -0,0 +1,62 @@ +package cli + +import "github.com/charmbracelet/bubbles/key" + +// keyMap defines the keybindings used across the CLI's TUIs. Single source of +// truth so help text and matching logic stay aligned, and so the strings "esc", +// "ctrl+c", etc. live in exactly one place. +type keyMap struct { + Quit key.Binding + Back key.Binding + Search key.Binding + Confirm key.Binding + Up key.Binding + Down key.Binding + NextPage key.Binding + PrevPage key.Binding + Home key.Binding + End key.Binding +} + +var keys = keyMap{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), + ), + Confirm: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + NextPage: key.NewBinding( + key.WithKeys("n", "right"), + key.WithHelp("n/→", "next page"), + ), + PrevPage: key.NewBinding( + key.WithKeys("p", "left"), + key.WithHelp("p/←", "prev page"), + ), + Home: key.NewBinding( + key.WithKeys("home", "g"), + key.WithHelp("g/home", "top"), + ), + End: key.NewBinding( + key.WithKeys("end", "G"), + key.WithHelp("G/end", "bottom"), + ), +} diff --git a/cmd/entire/cli/search_tui.go b/cmd/entire/cli/search_tui.go index 4274f3b63f..67aab1b779 100644 --- a/cmd/entire/cli/search_tui.go +++ b/cmd/entire/cli/search_tui.go @@ -11,6 +11,7 @@ import ( "charm.land/glamour/v2/ansi" glamourstyles "charm.land/glamour/v2/styles" "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -84,6 +85,13 @@ func newSearchStyles(ss statusStyles) searchStyles { return s } +// helpItem renders a " " pair for a TUI help footer using the +// shared helpKey style. keyLabel may come from a key.Binding's Help().Key or +// be a composite literal like "j/k". +func (s searchStyles) helpItem(keyLabel, desc string) string { + return s.render(s.helpKey, keyLabel) + " " + desc +} + const resultsPerPage = 25 // searchModel is the bubbletea model for interactive search results. @@ -249,13 +257,13 @@ func (m searchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:ireturn } func (m searchModel) updateSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:ireturn // bubbletea pattern - switch msg.String() { - case tuiEscKey: + switch { + case key.Matches(msg, keys.Back): m.mode = modeBrowse m.input.Blur() m = m.refreshBrowseContent() return m, nil - case "enter": + case key.Matches(msg, keys.Confirm): raw := strings.TrimSpace(m.input.Value()) if raw == "" { return m, nil @@ -291,25 +299,25 @@ func (m searchModel) updateSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //n func (m searchModel) updateBrowseMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:ireturn // bubbletea pattern pageLen := len(m.pageResults()) - switch msg.String() { - case "q", "ctrl+c", tuiEscKey, "h": + switch { + case key.Matches(msg, keys.Quit), key.Matches(msg, keys.Back), msg.String() == "h": return m, tea.Quit - case "up", "k": + case key.Matches(msg, keys.Up): if m.cursor > 0 { m.cursor-- m = m.refreshBrowseContent() } - case "down", "j": + case key.Matches(msg, keys.Down): if m.cursor < pageLen-1 { m.cursor++ m = m.refreshBrowseContent() } - case "home", "g": + case key.Matches(msg, keys.Home): m.page = 0 m.cursor = 0 m = m.refreshBrowseContent() m.browseVP.GotoTop() - case "end", "G": + case key.Matches(msg, keys.End): if len(m.results) > 0 { lastLoaded := len(m.results) - 1 m.page = min(lastLoaded/resultsPerPage, m.totalPages()-1) @@ -319,7 +327,7 @@ func (m searchModel) updateBrowseMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //n m = m.refreshBrowseContent() m.browseVP.GotoBottom() } - case "n", "right": + case key.Matches(msg, keys.NextPage): if m.page < m.totalPages()-1 { m.page++ m.cursor = 0 @@ -333,14 +341,14 @@ func (m searchModel) updateBrowseMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //n } m = m.refreshBrowseContent() } - case "p", "left": + case key.Matches(msg, keys.PrevPage): if m.page > 0 { m.page-- m.cursor = 0 m.browseVP.GotoTop() m = m.refreshBrowseContent() } - case "enter": + case key.Matches(msg, keys.Confirm): if r := m.selectedResult(); r != nil { m.mode = modeDetail content := m.renderDetailContent(*r, m.width, true) @@ -348,7 +356,7 @@ func (m searchModel) updateBrowseMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //n m.detailVP.SetContent(content) return m, nil } - case "/": + case key.Matches(msg, keys.Search): m.mode = modeSearch m.input.Focus() return m, m.input.Cursor.SetMode(cursor.CursorBlink) @@ -362,13 +370,13 @@ func (m searchModel) updateBrowseMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //n } func (m searchModel) updateDetailMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:ireturn // bubbletea pattern - switch msg.String() { - case tuiEscKey, "backspace": + switch { + case key.Matches(msg, keys.Quit): + return m, tea.Quit + case key.Matches(msg, keys.Back), msg.String() == "backspace": m.mode = modeBrowse return m, nil - case "q", "ctrl+c": - return m, tea.Quit - case "/": + case key.Matches(msg, keys.Search): m.mode = modeSearch m.input.Focus() return m, m.input.Cursor.SetMode(cursor.CursorBlink) @@ -693,11 +701,10 @@ func (m searchModel) viewDetailFull() string { // Scroll indicator + help scrollPct := m.styles.render(m.styles.dim, fmt.Sprintf("%3.f%%", m.detailVP.ScrollPercent()*100)) - help := m.styles.render(m.styles.helpKey, "j/k") + " scroll" + - m.styles.render(m.styles.helpSep, " · ") + - m.styles.render(m.styles.helpKey, tuiEscKey) + " back" + - m.styles.render(m.styles.helpSep, " · ") + - m.styles.render(m.styles.helpKey, "q") + " quit" + dot := m.styles.render(m.styles.helpSep, " · ") + help := m.styles.helpItem("j/k", "scroll") + dot + + m.styles.helpItem(keys.Back.Help().Key, keys.Back.Help().Desc) + dot + + m.styles.helpItem(keys.Quit.Help().Key, keys.Quit.Help().Desc) gap := m.width - lipgloss.Width(help) - lipgloss.Width(scrollPct) - 2 if gap < 1 { @@ -712,19 +719,19 @@ func (m searchModel) viewHelp() string { dot := m.styles.render(m.styles.helpSep, " · ") if m.mode == modeSearch { - return m.styles.render(m.styles.helpKey, "enter") + " search" + dot + - m.styles.render(m.styles.helpKey, tuiEscKey) + " cancel" + "\n" + return m.styles.helpItem(keys.Confirm.Help().Key, "search") + dot + + m.styles.helpItem(keys.Back.Help().Key, "cancel") + "\n" } pages := m.totalPages() - left := m.styles.render(m.styles.helpKey, "/") + " search" + dot + - m.styles.render(m.styles.helpKey, "↑/↓, j/k") + " scroll" + dot + - m.styles.render(m.styles.helpKey, "home/end, g/G") + " top/bottom" + left := m.styles.helpItem(keys.Search.Help().Key, keys.Search.Help().Desc) + dot + + m.styles.helpItem("↑/↓, j/k", "scroll") + dot + + m.styles.helpItem("home/end, g/G", "top/bottom") if pages > 1 { - left += dot + m.styles.render(m.styles.helpKey, "n/p") + " page" + left += dot + m.styles.helpItem("n/p", "page") } - left += dot + m.styles.render(m.styles.helpKey, "q") + " quit" + left += dot + m.styles.helpItem(keys.Quit.Help().Key, keys.Quit.Help().Desc) right := fmt.Sprintf("%d results", m.total) if pages > 1 {