Files
kitty/kittens/command_palette/main.go
Daniel M German 3d89cb267c Add alias and combine support to the command palette
Users who define action_alias or kitten_alias in kitty.conf had no way
to discover or trigger these custom commands from the command palette.
Aliased keybindings were miscategorized (landing in "Miscellaneous"
with no help text), and combine bindings had the same problem.

Changes:

- Resolve aliases via opts.alias_map to get correct action names,
  categories, and help text for aliased keybindings
- Add dedicated "Action aliases" and "Kitten aliases" sections that
  list all user-defined aliases, with bound aliases showing their key
  and unbound aliases browsable as unmapped entries
- Add a "Combined actions" section for combine keybindings
- Make alias names searchable in the Go TUI so users can find
  bindings by typing the alias name
- Fix action column highlight positions to match the scored text,
  preventing visual corruption when searching for alias names

Also refactors collect_keys_data into focused single-responsibility
functions and reduces nesting depth across both Python and Go.
2026-04-06 09:45:39 -07:00

1054 lines
30 KiB
Go

// License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
package command_palette
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/kovidgoyal/kitty/tools/cli"
"github.com/kovidgoyal/kitty/tools/config"
"github.com/kovidgoyal/kitty/tools/tty"
"github.com/kovidgoyal/kitty/tools/tui"
"github.com/kovidgoyal/kitty/tools/tui/loop"
"github.com/kovidgoyal/kitty/tools/utils"
"github.com/kovidgoyal/kitty/tools/wcswidth"
)
// JSON data structures matching Python collect_keys_data output
type Binding struct {
Key string `json:"key"`
Action string `json:"action"`
ActionDisplay string `json:"action_display"`
Definition string `json:"definition"`
Help string `json:"help"`
LongHelp string `json:"long_help"`
Alias string `json:"alias"`
Category string
Mode string
IsMouse bool
}
type InputData struct {
Modes map[string]map[string][]Binding `json:"modes"`
Mouse []Binding `json:"mouse"`
ModeOrder []string `json:"mode_order"`
CategoryOrder map[string][]string `json:"category_order"`
}
// wordToken represents a single word extracted from column text, with its
// rune-level position in the original string for match highlighting.
type wordToken struct {
word string // lowercased word
startPos int // rune offset in original column text
endPos int // rune offset past last char
}
// tokenizeWords splits s into words on delimiters (_ space + / -) and returns
// each word with its rune position in the original string.
func tokenizeWords(s string) []wordToken {
runes := []rune(strings.ToLower(s))
var tokens []wordToken
start := -1
for i, r := range runes {
if isWordDelimiter(r) {
if start >= 0 {
tokens = append(tokens, wordToken{
word: string(runes[start:i]),
startPos: start,
endPos: i,
})
start = -1
}
} else if start < 0 {
start = i
}
}
if start >= 0 {
tokens = append(tokens, wordToken{
word: string(runes[start:]),
startPos: start,
endPos: len(runes),
})
}
return tokens
}
// tokenizeQuery splits a query string into lowercase tokens on whitespace only.
// Delimiter characters like _ + / - are preserved within tokens so the user can
// search for compound names (e.g. "mouse_selection") as a single unit.
func tokenizeQuery(s string) []string {
parts := strings.Fields(s)
for i := range parts {
parts[i] = strings.ToLower(parts[i])
}
return parts
}
// isWordDelimiter returns true for characters used to split column text into words.
func isWordDelimiter(r rune) bool {
return r == '_' || r == ' ' || r == '+' || r == '/' || r == '-'
}
// matchSingleWord finds the best-matching word for a simple (no-delimiter) query
// token against a column's pre-tokenized words.
//
// Scoring: exact=4, prefix=3, edit-distance-1=2, edit-distance-2=1, none=0.
func matchSingleWord(queryToken string, words []wordToken) (score int, positions []int) {
for _, w := range words {
var s int
var pos []int
if w.word == queryToken {
// Exact match
s = 4
pos = runeRange(w.startPos, w.endPos)
} else if strings.HasPrefix(w.word, queryToken) {
// Prefix match — highlight only the matched prefix
s = 3
pos = runeRange(w.startPos, w.startPos+len([]rune(queryToken)))
} else if len(queryToken) >= 4 && len(w.word) >= 4 {
// Typo tolerance via edit distance (only for words >= 4 chars)
dist := utils.LevenshteinDistance(queryToken, w.word, false)
if dist == 1 {
s = 2
pos = runeRange(w.startPos, w.endPos)
} else if dist == 2 {
s = 1
pos = runeRange(w.startPos, w.endPos)
}
}
if s > score {
score = s
positions = pos
}
}
return
}
// bestWordMatch finds the best match for queryToken against a column's words.
// For compound tokens (containing _ + / -), it first tries an exact substring
// match against the full column text, then falls back to matching each sub-part
// independently against individual words.
func bestWordMatch(queryToken string, words []wordToken, colText string) (score int, positions []int) {
if !strings.ContainsAny(queryToken, "_+/-") {
return matchSingleWord(queryToken, words)
}
// Compound token: try exact substring match in the column text
colLower := strings.ToLower(colText)
if idx := strings.Index(colLower, queryToken); idx != -1 {
runeIdx := len([]rune(colLower[:idx]))
qRuneLen := len([]rune(queryToken))
subParts := strings.FieldsFunc(queryToken, isWordDelimiter)
return 4 * len(subParts), runeRange(runeIdx, runeIdx+qRuneLen)
}
// Fallback: match each sub-part independently against words
subParts := strings.FieldsFunc(queryToken, isWordDelimiter)
var totalScore int
var allPos []int
for _, sub := range subParts {
s, p := matchSingleWord(sub, words)
totalScore += s
allPos = append(allPos, p...)
}
if totalScore > 0 {
return totalScore, allPos
}
return 0, nil
}
// runeRange returns a slice of consecutive ints from start to end-1.
func runeRange(start, end int) []int {
pos := make([]int, end-start)
for i := range pos {
pos[i] = start + i
}
return pos
}
// DisplayItem wraps a binding with its per-column search texts and pre-tokenized
// words for word-level matching.
type DisplayItem struct {
binding Binding
keyText string
actionText string
categoryText string
keyWords []wordToken
actionWords []wordToken
categoryWords []wordToken
}
// matchInfo stores matched character positions per column for multi-token highlighting
type matchInfo struct {
keyPositions []int // matched rune positions in key column
actionPositions []int // matched rune positions in action column
categoryPositions []int // matched rune positions in category column
}
// scoredItem holds ranking data for a single item matching the current query.
type scoredItem struct {
idx int
nMatched int
actionScore int
keyScore int
categoryScore int
mi matchInfo
}
type displayLine struct {
text string
isHeader bool
isModeHdr bool
itemIdx int // index into filtered_idx, -1 for headers
}
const maxKeyDisplayWidth = 30
// unmappedLabel is shown in the key column for actions with no keyboard shortcut.
const unmappedLabel = "(unmapped)"
// truncateToWidth truncates s to fit within maxWidth cells, appending "..." if
// truncated and maxWidth > 3. When maxWidth <= 3, the string is simply trimmed
// to fit without appending ellipsis (no room for it).
func truncateToWidth(s string, maxWidth int) string {
if wcswidth.Stringwidth(s) <= maxWidth {
return s
}
runes := []rune(s)
if maxWidth <= 3 {
// Not enough room for ellipsis; just trim to fit
for len(runes) > 0 && wcswidth.Stringwidth(string(runes)) > maxWidth {
runes = runes[:len(runes)-1]
}
return string(runes)
}
for len(runes) > 0 && wcswidth.Stringwidth(string(runes))+3 > maxWidth {
runes = runes[:len(runes)-1]
}
return string(runes) + "..."
}
// sectionHeader returns a separator line like " ── label ─────────".
func sectionHeader(label string, width int) string {
labelWidth := wcswidth.Stringwidth(label)
sepLen := max(0, width-labelWidth-6)
sep := strings.Repeat("\u2500", sepLen)
return fmt.Sprintf(" \u2500\u2500 %s %s", label, sep)
}
// keyDisplayText returns the display string for a binding's key column,
// substituting unmappedLabel for empty keys and truncating to maxKeyDisplayWidth.
func keyDisplayText(b *Binding) string {
key := b.Key
if key == "" {
key = unmappedLabel
}
return truncateToWidth(key, maxKeyDisplayWidth)
}
type CachedSettings struct {
ShowUnmapped bool `json:"show_unmapped"`
}
type Handler struct {
lp *loop.Loop
screen_size loop.ScreenSize
all_items []DisplayItem
filtered_idx []int // indices into all_items for current results
match_infos []matchInfo // parallel to filtered_idx, valid when query != ""
query string
selected_idx int
scroll_offset int
input_data InputData
result string // action definition to execute after exit
display_lines []displayLine
results_start_y int
results_height int
show_unmapped bool
cv *utils.CachedValues[*CachedSettings]
shortcut_tracker config.ShortcutTracker
keyboard_shortcuts []*config.KeyAction
}
// initialize sets up the TUI: screen size, cursor, cached settings, and initial data load.
func (h *Handler) initialize() (string, error) {
sz, err := h.lp.ScreenSize()
if err != nil {
return "", err
}
h.screen_size = sz
h.lp.SetCursorVisible(true)
h.lp.SetCursorShape(loop.BAR_CURSOR, true)
h.lp.AllowLineWrapping(false)
h.lp.SetWindowTitle("Command Palette")
// Initialize with ShowUnmapped: true as the default; Load() returns this
// default when no cache file exists yet.
h.cv = utils.NewCachedValues("command-palette", &CachedSettings{ShowUnmapped: true})
settings := h.cv.Load()
h.show_unmapped = settings.ShowUnmapped
h.keyboard_shortcuts = config.ResolveShortcuts(NewConfig().KeyboardShortcuts)
if err := h.loadData(); err != nil {
return "", err
}
h.updateFilter()
h.draw_screen()
h.lp.SendOverlayReady()
return "", nil
}
// loadData reads JSON input data from stdin and flattens it into display items.
func (h *Handler) loadData() error {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read stdin: %w", err)
}
if len(data) == 0 {
return fmt.Errorf("no input data received on stdin; this kitten must be launched from kitty")
}
if err := json.Unmarshal(data, &h.input_data); err != nil {
return fmt.Errorf("failed to parse input data: %w", err)
}
h.flattenBindings()
return nil
}
// bindingToDisplayItem converts a Binding into a DisplayItem with pre-tokenized
// words for word-level matching.
func bindingToDisplayItem(b Binding) DisplayItem {
keyText := b.Key
if keyText == "" {
keyText = unmappedLabel
}
actionText := b.ActionDisplay
if b.Alias != "" {
actionText = b.Alias + " " + actionText
}
return DisplayItem{
binding: b,
keyText: keyText,
actionText: actionText,
categoryText: b.Category,
keyWords: tokenizeWords(keyText),
actionWords: tokenizeWords(actionText),
categoryWords: tokenizeWords(b.Category),
}
}
// flattenCategoryBindings appends all bindings from a single category to items.
func flattenCategoryBindings(bindings []Binding, catName, modeName string, items *[]DisplayItem) {
for _, b := range bindings {
b.Category = catName
b.Mode = modeName
b.IsMouse = false
*items = append(*items, bindingToDisplayItem(b))
}
}
// flattenBindings converts the hierarchical mode/category/binding data into
// a flat list suitable for display and word-level scoring. Uses the explicit
// ordering arrays from Python since Go maps do not preserve insertion order.
func (h *Handler) flattenBindings() {
// Use explicit mode ordering from Python, falling back to sorted keys
modeNames := h.input_data.ModeOrder
if len(modeNames) == 0 {
modeNames = make([]string, 0, len(h.input_data.Modes))
for name := range h.input_data.Modes {
modeNames = append(modeNames, name)
}
sort.Slice(modeNames, func(i, j int) bool {
if modeNames[i] == "" {
return true
}
if modeNames[j] == "" {
return false
}
return modeNames[i] < modeNames[j]
})
}
for _, modeName := range modeNames {
categories, ok := h.input_data.Modes[modeName]
if !ok {
continue
}
// Use explicit category ordering from Python, falling back to sorted keys
catNames := h.input_data.CategoryOrder[modeName]
if len(catNames) == 0 {
catNames = make([]string, 0, len(categories))
for name := range categories {
catNames = append(catNames, name)
}
sort.Strings(catNames)
}
for _, catName := range catNames {
bindings, ok := categories[catName]
if !ok {
continue
}
flattenCategoryBindings(bindings, catName, modeName, &h.all_items)
}
}
// Mouse bindings
for _, b := range h.input_data.Mouse {
b.Category = "Mouse actions"
b.Mode = ""
b.IsMouse = true
h.all_items = append(h.all_items, bindingToDisplayItem(b))
}
}
// updateFilter rebuilds the filtered item list based on the current query.
func (h *Handler) updateFilter() {
tokens := tokenizeQuery(h.query)
if len(tokens) == 0 {
// Show all items in original order, respecting the show_unmapped toggle
h.filtered_idx = make([]int, 0, len(h.all_items))
for i, item := range h.all_items {
if !h.show_unmapped && item.binding.Key == "" {
continue
}
h.filtered_idx = append(h.filtered_idx, i)
}
h.match_infos = nil
h.selected_idx = 0
h.scroll_offset = 0
return
}
var matches []scoredItem
for i := range h.all_items {
if !h.show_unmapped && h.all_items[i].binding.Key == "" {
continue
}
item := &h.all_items[i]
var s scoredItem
s.idx = i
for _, qt := range tokens {
ks, kp := bestWordMatch(qt, item.keyWords, item.keyText)
as, ap := bestWordMatch(qt, item.actionWords, item.actionText)
cs, cp := bestWordMatch(qt, item.categoryWords, item.categoryText)
best := max(ks, max(as, cs))
if best > 0 {
s.nMatched++
}
s.keyScore += ks
s.actionScore += as
s.categoryScore += cs
s.mi.keyPositions = append(s.mi.keyPositions, kp...)
s.mi.actionPositions = append(s.mi.actionPositions, ap...)
s.mi.categoryPositions = append(s.mi.categoryPositions, cp...)
}
if s.nMatched > 0 {
matches = append(matches, s)
}
}
// Sort: most tokens matched > actionScore > keyScore > categoryScore > shorter ActionDisplay
sort.Slice(matches, func(i, j int) bool {
if matches[i].nMatched != matches[j].nMatched {
return matches[i].nMatched > matches[j].nMatched
}
if matches[i].actionScore != matches[j].actionScore {
return matches[i].actionScore > matches[j].actionScore
}
if matches[i].keyScore != matches[j].keyScore {
return matches[i].keyScore > matches[j].keyScore
}
if matches[i].categoryScore != matches[j].categoryScore {
return matches[i].categoryScore > matches[j].categoryScore
}
return len(h.all_items[matches[i].idx].binding.ActionDisplay) < len(h.all_items[matches[j].idx].binding.ActionDisplay)
})
// Build filtered_idx and match_infos
h.filtered_idx = make([]int, len(matches))
h.match_infos = make([]matchInfo, len(matches))
for i, m := range matches {
h.filtered_idx[i] = m.idx
h.match_infos[i] = m.mi
}
h.selected_idx = 0
h.scroll_offset = 0
}
// highlightMatchedChars returns a string with characters at the given rune
// positions rendered using matchStyle, and the rest rendered using baseStyle
// (or unstyled if baseStyle is empty).
func (h *Handler) highlightMatchedChars(text string, positions []int, baseStyle, matchStyle string) string {
if len(positions) == 0 {
if baseStyle != "" {
return h.lp.SprintStyled(baseStyle, text)
}
return text
}
posSet := make(map[int]bool, len(positions))
for _, p := range positions {
posSet[p] = true
}
runes := []rune(text)
var sb strings.Builder
for i, r := range runes {
ch := string(r)
if posSet[i] {
sb.WriteString(h.lp.SprintStyled(matchStyle, ch))
} else if baseStyle != "" {
sb.WriteString(h.lp.SprintStyled(baseStyle, ch))
} else {
sb.WriteString(ch)
}
}
return sb.String()
}
// selectedBinding returns the currently selected binding, or nil if none.
func (h *Handler) selectedBinding() *Binding {
if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) {
return nil
}
idx := h.filtered_idx[h.selected_idx]
if idx < 0 || idx >= len(h.all_items) {
return nil
}
return &h.all_items[idx].binding
}
// draw_screen renders the full palette UI: query input, help bar, and results.
func (h *Handler) draw_screen() {
h.lp.StartAtomicUpdate()
defer h.lp.EndAtomicUpdate()
h.lp.ClearScreen()
width := int(h.screen_size.WidthCells)
height := int(h.screen_size.HeightCells)
if width < 10 || height < 5 {
return
}
// Layout: line 1 = search bar, lines 2..height-2 = results,
// line height-1 = help text, line height = key hints
searchBarY := 1
resultsStartY := 2
helpY := height - 1
hintsY := height
resultsHeight := max(helpY-resultsStartY, 1)
h.results_start_y = resultsStartY
h.results_height = resultsHeight
// Draw search bar
h.lp.MoveCursorTo(1, searchBarY)
h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-yellow", "> "))
h.lp.QueueWriteString(h.query)
// Draw results
if h.query == "" {
h.drawGroupedResults(resultsStartY, resultsHeight, width)
} else {
h.drawFlatResults(resultsStartY, resultsHeight, width)
}
// Draw help text for selected binding
h.lp.MoveCursorTo(1, helpY)
if b := h.selectedBinding(); b != nil && b.Help != "" {
helpStr := truncateToWidth(b.Help, max(width-2, 3))
h.lp.QueueWriteString(h.lp.SprintStyled("dim italic", " "+helpStr))
}
// Draw key hints footer
h.lp.MoveCursorTo(1, hintsY)
unmappedToggleLabel := "Show"
if h.show_unmapped {
unmappedToggleLabel = "Hide"
}
footer := h.lp.SprintStyled("fg=bright-yellow", "[Enter]") + " Run " +
h.lp.SprintStyled("fg=bright-yellow", "[Esc]") + " Quit " +
h.lp.SprintStyled("fg=bright-yellow", "\u2191\u2193") + " Navigate " +
h.lp.SprintStyled("fg=bright-yellow", "[F12]") + " " + unmappedToggleLabel + " unmapped"
matchCount := ""
if h.query != "" {
matchCount = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items))
}
h.lp.QueueWriteString(" " + footer + h.lp.SprintStyled("dim", matchCount))
// Position cursor at end of search text for typing
h.lp.MoveCursorTo(3+wcswidth.Stringwidth(h.query), searchBarY)
}
// drawGroupedResults renders results organized by mode and category headers.
func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
var lines []displayLine
lastMode := ""
lastCategory := ""
for fi, idx := range h.filtered_idx {
item := &h.all_items[idx]
b := &item.binding
// Mode header when mode changes
if b.Mode != lastMode {
lastMode = b.Mode
lastCategory = ""
if b.Mode != "" {
if len(lines) > 0 {
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
}
lines = append(lines, displayLine{
text: sectionHeader("Keyboard mode: "+b.Mode, width),
isModeHdr: true, isHeader: true, itemIdx: -1,
})
}
}
// Category header when category changes - only for the default mode ("")
if b.Mode == "" && b.Category != lastCategory {
lastCategory = b.Category
if len(lines) > 0 && !lines[len(lines)-1].isHeader {
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
}
lines = append(lines, displayLine{
text: sectionHeader(b.Category, width),
isHeader: true, itemIdx: -1,
})
}
// Binding line
keyDisplay := keyDisplayText(b)
lines = append(lines, displayLine{
text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, item.actionText),
itemIdx: fi,
})
}
h.display_lines = lines
h.drawLines(lines, startY, maxRows, width)
}
// drawFlatResults renders a flat list of scored results without category headers.
func (h *Handler) drawFlatResults(startY, maxRows, width int) {
if len(h.filtered_idx) == 0 {
h.lp.MoveCursorTo(1, startY)
h.lp.QueueWriteString(h.lp.SprintStyled("italic dim", " No matches found"))
h.display_lines = []displayLine{}
return
}
var lines []displayLine
for fi, idx := range h.filtered_idx {
item := &h.all_items[idx]
b := &item.binding
keyDisplay := keyDisplayText(b)
catSuffix := ""
if b.Mode != "" {
catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)
} else {
catSuffix = fmt.Sprintf(" [%s]", b.Category)
}
lines = append(lines, displayLine{
text: fmt.Sprintf(" %-*s %-30s%s", maxKeyDisplayWidth, keyDisplay, item.actionText, catSuffix),
itemIdx: fi,
})
}
h.display_lines = lines
h.drawLines(lines, startY, maxRows, width)
}
// drawLines renders display lines within the visible scroll window.
func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) {
if maxRows <= 0 || len(lines) == 0 {
return
}
// Adjust scroll to keep selected item visible
selectedLineIdx := -1
for i, dl := range lines {
if dl.itemIdx == h.selected_idx {
selectedLineIdx = i
break
}
}
if selectedLineIdx >= 0 {
if selectedLineIdx < h.scroll_offset {
// Scroll up to show selected item; also reveal any header lines above it
h.scroll_offset = selectedLineIdx
for h.scroll_offset > 0 && lines[h.scroll_offset-1].isHeader {
h.scroll_offset--
}
}
if selectedLineIdx >= h.scroll_offset+maxRows {
h.scroll_offset = selectedLineIdx - maxRows + 1
}
}
h.scroll_offset = max(0, h.scroll_offset)
h.scroll_offset = min(h.scroll_offset, max(0, len(lines)-maxRows))
end := min(h.scroll_offset+maxRows, len(lines))
for row, li := range lines[h.scroll_offset:end] {
h.lp.MoveCursorTo(1, startY+row)
text := li.text
// Truncate at rune boundary to avoid breaking multi-byte characters
if wcswidth.Stringwidth(text) > width {
runes := []rune(text)
for len(runes) > 0 && wcswidth.Stringwidth(string(runes)) > width {
runes = runes[:len(runes)-1]
}
text = string(runes)
}
if li.isModeHdr {
h.lp.QueueWriteString(h.lp.SprintStyled("bold fg=magenta", text))
} else if li.isHeader {
h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-blue", text))
} else if li.itemIdx == h.selected_idx {
// Selected item: highlight with reverse video, preserving match highlights
h.drawBindingLine(li.itemIdx, width, true)
} else {
h.drawBindingLine(li.itemIdx, width, false)
}
}
}
// drawCategorySuffix renders the " [category]" or " [mode/category]" suffix
// with optional match highlighting.
func (h *Handler) drawCategorySuffix(b *Binding, mi *matchInfo, baseStyle, matchStyle string) {
styled := func(s string) string {
if baseStyle != "" {
return h.lp.SprintStyled(baseStyle, s)
}
return s
}
prefix := " ["
if b.Mode != "" {
prefix = fmt.Sprintf(" [%s/", b.Mode)
}
if mi != nil && len(mi.categoryPositions) > 0 {
h.lp.QueueWriteString(styled(prefix))
h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.categoryPositions, baseStyle, matchStyle))
h.lp.QueueWriteString(styled("]"))
} else {
h.lp.QueueWriteString(styled(prefix + b.Category + "]"))
}
}
// categorySuffixWidth returns the display width of the category suffix.
func categorySuffixWidth(b *Binding) int {
w := 2 + wcswidth.Stringwidth(b.Category) + 1 // " [" + category + "]"
if b.Mode != "" {
w += wcswidth.Stringwidth(b.Mode) + 1 // mode + "/"
}
return w
}
// drawBindingLine renders a single binding row with key, action, and optional category.
func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) {
if filteredIdx < 0 || filteredIdx >= len(h.filtered_idx) {
return
}
idx := h.filtered_idx[filteredIdx]
if idx < 0 || idx >= len(h.all_items) {
return
}
b := &h.all_items[idx].binding
actionDisplay := h.all_items[idx].actionText
// Build the key display
keyDisplay := keyDisplayText(b)
// Determine match info for highlighting (only set when a query is active)
var mi *matchInfo
if h.query != "" && filteredIdx < len(h.match_infos) {
mi = &h.match_infos[filteredIdx]
}
// Style definitions vary based on whether this row is selected
var matchStyle, keyStyle, unmappedStyle, baseStyle string
if isSelected {
matchStyle = "fg=bright-yellow reverse"
keyStyle = "fg=green reverse"
unmappedStyle = "dim fg=green reverse"
baseStyle = "reverse"
} else {
matchStyle = "fg=bright-yellow"
keyStyle = "fg=green"
unmappedStyle = "dim fg=green"
}
// styled applies baseStyle to s when selected, or returns s unchanged.
styled := func(s string) string {
if baseStyle != "" {
return h.lp.SprintStyled(baseStyle, s)
}
return s
}
// Render key column (4-space indent + key padded to maxKeyDisplayWidth + space)
paddingLen := max(0, maxKeyDisplayWidth-wcswidth.Stringwidth(keyDisplay))
pad := strings.Repeat(" ", paddingLen) + " "
ks := keyStyle
if b.Key == "" {
ks = unmappedStyle
}
if mi != nil && len(mi.keyPositions) > 0 {
h.lp.QueueWriteString(styled(" "))
h.lp.QueueWriteString(h.highlightMatchedChars(keyDisplay, mi.keyPositions, ks, matchStyle))
h.lp.QueueWriteString(styled(pad))
} else {
h.lp.QueueWriteString(h.lp.SprintStyled(ks, " "+keyDisplay+pad))
}
// Render action display column
if mi != nil && len(mi.actionPositions) > 0 {
h.lp.QueueWriteString(h.highlightMatchedChars(actionDisplay, mi.actionPositions, baseStyle, matchStyle))
} else {
h.lp.QueueWriteString(styled(actionDisplay))
}
// Render category suffix (only present in flat / search-results mode)
if h.query != "" {
h.drawCategorySuffix(b, mi, baseStyle, matchStyle)
}
// For selected rows, pad the rest of the line with reverse video
if isSelected {
rendered := 4 + wcswidth.Stringwidth(keyDisplay) + paddingLen + 1 + wcswidth.Stringwidth(actionDisplay)
if h.query != "" {
rendered += categorySuffixWidth(b)
}
if pad := width - rendered; pad > 0 {
h.lp.QueueWriteString(h.lp.SprintStyled(baseStyle, strings.Repeat(" ", pad)))
}
}
}
// rowToFilteredIdx converts a 0-indexed cell Y coordinate to a filtered item
// index, or -1 if the cell is not over a clickable item. Internally converts
// to 1-indexed screen rows (matching the MoveCursorTo convention) to compare
// against results_start_y.
func (h *Handler) rowToFilteredIdx(cellY int) int {
screenRow := cellY + 1 // convert 0-indexed cell to 1-indexed screen row
if screenRow < h.results_start_y || screenRow >= h.results_start_y+h.results_height {
return -1
}
lineIdx := h.scroll_offset + (screenRow - h.results_start_y)
if lineIdx < 0 || lineIdx >= len(h.display_lines) {
return -1
}
return h.display_lines[lineIdx].itemIdx
}
// onMouseEvent handles mouse clicks to select and trigger items.
func (h *Handler) onMouseEvent(ev *loop.MouseEvent) error {
switch ev.Event_type {
case loop.MOUSE_CLICK:
if ev.Buttons&loop.LEFT_MOUSE_BUTTON != 0 {
fi := h.rowToFilteredIdx(ev.Cell.Y)
if fi >= 0 {
h.selected_idx = fi
h.triggerSelected()
}
}
case loop.MOUSE_MOVE:
fi := h.rowToFilteredIdx(ev.Cell.Y)
h.lp.ClearPointerShapes()
if fi >= 0 {
h.lp.PushPointerShape(loop.POINTER_POINTER)
}
}
return nil
}
// onKeyEvent handles keyboard input for navigation, selection, and query editing.
func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("escape") {
ev.Handled = true
if h.query != "" {
h.query = ""
h.updateFilter()
h.draw_screen()
} else {
h.lp.Quit(0)
}
return nil
}
if ev.MatchesPressOrRepeat("enter") {
ev.Handled = true
h.triggerSelected()
return nil
}
if ev.MatchesPressOrRepeat("up") {
ev.Handled = true
h.moveSelection(-1)
return nil
}
if ev.MatchesPressOrRepeat("down") {
ev.Handled = true
h.moveSelection(1)
return nil
}
if ac := h.shortcut_tracker.Match(ev, h.keyboard_shortcuts); ac != nil {
ev.Handled = true
switch ac.Name {
case "selection_up":
h.moveSelection(-1)
case "selection_down":
h.moveSelection(1)
}
return nil
}
if ev.MatchesPressOrRepeat("page_up") {
ev.Handled = true
delta := max(1, int(h.screen_size.HeightCells)-4)
h.moveSelection(-delta)
return nil
}
if ev.MatchesPressOrRepeat("page_down") {
ev.Handled = true
delta := max(1, int(h.screen_size.HeightCells)-4)
h.moveSelection(delta)
return nil
}
if ev.MatchesPressOrRepeat("home") || ev.MatchesPressOrRepeat("ctrl+home") {
ev.Handled = true
h.selected_idx = 0
h.draw_screen()
return nil
}
if ev.MatchesPressOrRepeat("end") || ev.MatchesPressOrRepeat("ctrl+end") {
ev.Handled = true
if len(h.filtered_idx) > 0 {
h.selected_idx = len(h.filtered_idx) - 1
}
h.draw_screen()
return nil
}
if ev.MatchesPressOrRepeat("backspace") {
ev.Handled = true
if h.query != "" {
g := wcswidth.SplitIntoGraphemes(h.query)
h.query = strings.Join(g[:len(g)-1], "")
h.updateFilter()
h.draw_screen()
} else {
h.lp.Beep()
}
return nil
}
if ev.MatchesPressOrRepeat("f12") {
ev.Handled = true
h.show_unmapped = !h.show_unmapped
if h.cv != nil {
h.cv.Opts.ShowUnmapped = h.show_unmapped
h.cv.Save()
}
h.updateFilter()
h.draw_screen()
return nil
}
return nil
}
// onText handles typed characters, appending them to the search query.
func (h *Handler) onText(text string, from_key_event bool, in_bracketed_paste bool) error {
h.query += text
h.updateFilter()
h.draw_screen()
return nil
}
// onResize redraws the screen when the terminal is resized.
func (h *Handler) onResize(old, new_size loop.ScreenSize) error {
h.screen_size = new_size
h.draw_screen()
return nil
}
// moveSelection moves the selected item by delta positions, clamping to bounds.
func (h *Handler) moveSelection(delta int) {
if len(h.filtered_idx) == 0 {
return
}
h.selected_idx += delta
h.selected_idx = max(0, h.selected_idx)
h.selected_idx = min(h.selected_idx, len(h.filtered_idx)-1)
h.draw_screen()
}
// triggerSelected sets the selected binding's definition as the result and exits.
func (h *Handler) triggerSelected() {
b := h.selectedBinding()
if b == nil || b.IsMouse {
h.lp.Beep()
return
}
h.result = b.Definition
h.lp.Quit(0)
}
// main runs the command palette TUI as a kitty overlay.
func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
if tty.IsTerminal(os.Stdin.Fd()) {
return 1, fmt.Errorf("This kitten must only be run via the command_palette action mapped to a shortcut in kitty.conf")
}
output := tui.KittenOutputSerializer()
lp, err := loop.New()
if err != nil {
return 1, err
}
handler := &Handler{lp: lp}
lp.MouseTrackingMode(loop.FULL_MOUSE_TRACKING)
lp.OnInitialize = func() (string, error) {
return handler.initialize()
}
lp.OnFinalize = func() string { return "" }
lp.OnKeyEvent = handler.onKeyEvent
lp.OnText = handler.onText
lp.OnResize = handler.onResize
lp.OnMouseEvent = handler.onMouseEvent
err = lp.Run()
if err != nil {
return 1, err
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal:", ds)
lp.KillIfSignalled()
return
}
rc = lp.ExitCode()
if handler.result != "" {
s, serr := output(map[string]string{"action": handler.result})
if serr == nil {
fmt.Println(s)
}
}
return
}
// EntryPoint registers the command palette subcommand on the parent CLI.
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}