Command palette: word-level search with multi-token cross-column matching

Implement a word-level scoring engine for the command palette that replaces
the previous FZF-based approach. Query tokens are matched against pre-tokenized
words in each column (key, action, category) with exact, prefix, and
edit-distance scoring. Multiple search terms are supported with cross-column
matching — items matching more tokens rank higher. Compound query tokens
containing delimiters (e.g. mouse_selection) are matched as units.

Add comprehensive tests using a Go builder API instead of raw JSON blobs,
covering single-token, multi-token, partial-match, ranking, mouse binding,
and unmapped action scenarios.

Add documentation for the command palette kitten.
This commit is contained in:
Daniel M German
2026-03-22 17:13:13 -07:00
parent e9661f0f3a
commit 6a3fe6ae01
5 changed files with 1189 additions and 256 deletions

View File

@@ -0,0 +1,114 @@
Command palette
=================
.. only:: man
Overview
--------------
The command palette lets you browse, search and trigger all keyboard shortcuts
and actions in |kitty| from a single searchable overlay. Press
:sc:`command_palette` to open it (default: :kbd:`Ctrl+Shift+F3`).
.. figure:: ../screenshots/command-palette.png
:alt: A screenshot of the command palette kitten
:align: center
:width: 100%
The command palette showing search results for "window close"
All mapped actions (those with a keyboard shortcut) and unmapped actions (those
available but not bound to any key) are listed, organized by category. Mouse
bindings are shown in a separate section. Simply type to search, select a
result, and press :kbd:`Enter` to run it.
Searching
-----------
As you type into the search bar, the palette filters results in real time using
word-level matching across three columns: key, action, and category.
Multiple search terms are supported. Typing ``scroll page`` matches items that
contain both "scroll" and "page" in any column. Items matching more of your
search terms rank higher than those matching fewer.
The search also handles compound tokens that contain delimiters such as
underscores or slashes. For example, typing ``mouse_selection`` matches the
full compound name as a unit. Typo tolerance is built in for words of four
characters or longer.
Matched characters are highlighted in the results so you can see exactly where
each term matched.
Keyboard controls
-------------------
The following keys are available while the command palette is open:
.. list-table::
:widths: auto
:header-rows: 1
* - Key
- Action
* - Any text
- Filter results by typing a search query
* - :kbd:`Enter`
- Run the selected action
* - :kbd:`Escape`
- Clear the search query, or close the palette if the query is already empty
* - :kbd:`Up` / :kbd:`Ctrl+K` / :kbd:`Ctrl+P`
- Move selection up
* - :kbd:`Down` / :kbd:`Ctrl+J` / :kbd:`Ctrl+N`
- Move selection down
* - :kbd:`Page Up`
- Move selection up by a page
* - :kbd:`Page Down`
- Move selection down by a page
* - :kbd:`Home`
- Jump to the first result
* - :kbd:`End`
- Jump to the last result
* - :kbd:`Backspace`
- Delete the last character from the query
* - :kbd:`F12`
- Toggle display of unmapped actions
* - Mouse click
- Select and run the clicked action
Unmapped actions
------------------
By default, the palette shows both mapped actions (those bound to a shortcut)
and unmapped actions (those with no shortcut assigned). Unmapped actions appear
with an ``(unmapped)`` label in the key column. Press :kbd:`F12` to toggle
their visibility. This preference is remembered across sessions.
Unmapped actions are useful for discovering functionality that you may not have
configured a shortcut for. You can run them directly from the palette, or note
the action name and add a mapping in :file:`kitty.conf`.
Custom keyboard modes
-----------------------
If you have defined custom :ref:`keyboard modes <modal_mappings>` in your
configuration, their bindings appear under separate mode headers in the palette.
The ``push_keyboard_mode`` bindings are grouped with the target mode they
activate, making it easy to see how to enter each mode alongside its shortcuts.
Configuration
--------------
The default mapping to open the command palette is::
map kitty_mod+f3 command_palette
You can change this in :file:`kitty.conf` like any other mapping. For example::
map ctrl+p command_palette

View File

@@ -13,6 +13,7 @@ Extend with kittens
kittens/themes
kittens/choose-fonts
kittens/hints
kittens/command-palette
kittens/quick-access-terminal
kittens/choose-files
kittens/panel
@@ -54,6 +55,11 @@ Some prominent kittens:
filenames, words, lines, etc. from the terminal screen.
:doc:`Command palette <kittens/command-palette>`
Browse, search and trigger all keyboard shortcuts and actions from a
single searchable overlay.
:doc:`Quick access terminal <kittens/quick-access-terminal>`
Get access to a quick access floating, semi-transparent kitty window
with a single keypress.

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

View File

@@ -11,7 +11,6 @@ import (
"strings"
"github.com/kovidgoyal/kitty/tools/cli"
"github.com/kovidgoyal/kitty/tools/fzf"
"github.com/kovidgoyal/kitty/tools/tty"
"github.com/kovidgoyal/kitty/tools/tui"
"github.com/kovidgoyal/kitty/tools/tui/loop"
@@ -39,16 +38,166 @@ type InputData struct {
CategoryOrder map[string][]string `json:"category_order"`
}
// DisplayItem wraps a binding with its per-column search texts for FZF scoring
type DisplayItem struct {
binding Binding
colTexts [3]string // [0]=key, [1]=action_display, [2]=category
// 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
}
// matchInfo stores which column matched and the matched character positions
// 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 {
colIdx int // which column matched: 0=key, 1=action_display, 2=category
positions []int // rune positions in the matched column text
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 {
@@ -84,7 +233,24 @@ func truncateToWidth(s string, maxWidth int) string {
return string(runes) + "..."
}
// CachedSettings holds persistent UI settings stored in command-palette.json.
// 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"`
}
@@ -93,7 +259,6 @@ type Handler struct {
lp *loop.Loop
screen_size loop.ScreenSize
all_items []DisplayItem
matcher *fzf.FuzzyMatcher
filtered_idx []int // indices into all_items for current results
match_infos []matchInfo // parallel to filtered_idx, valid when query != ""
query string
@@ -129,7 +294,6 @@ func (h *Handler) initialize() (string, error) {
return "", err
}
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
h.updateFilter()
h.draw_screen()
h.lp.SendOverlayReady()
@@ -153,8 +317,8 @@ func (h *Handler) loadData() error {
}
// flattenBindings converts the hierarchical mode/category/binding data into
// a flat list suitable for display and FZF scoring. Uses the explicit ordering
// arrays from Python since Go maps do not preserve insertion order.
// 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
@@ -204,8 +368,13 @@ func (h *Handler) flattenBindings() {
keyText = unmappedLabel
}
h.all_items = append(h.all_items, DisplayItem{
binding: b,
colTexts: [3]string{keyText, b.ActionDisplay, catName},
binding: b,
keyText: keyText,
actionText: b.ActionDisplay,
categoryText: catName,
keyWords: tokenizeWords(keyText),
actionWords: tokenizeWords(b.ActionDisplay),
categoryWords: tokenizeWords(catName),
})
}
}
@@ -217,14 +386,21 @@ func (h *Handler) flattenBindings() {
b.Mode = ""
b.IsMouse = true
h.all_items = append(h.all_items, DisplayItem{
binding: b,
colTexts: [3]string{b.Key, b.ActionDisplay, "Mouse actions"},
binding: b,
keyText: b.Key,
actionText: b.ActionDisplay,
categoryText: "Mouse actions",
keyWords: tokenizeWords(b.Key),
actionWords: tokenizeWords(b.ActionDisplay),
categoryWords: tokenizeWords("Mouse actions"),
})
}
}
func (h *Handler) updateFilter() {
if h.query == "" {
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 {
@@ -239,62 +415,60 @@ func (h *Handler) updateFilter() {
return
}
nItems := len(h.all_items)
// Build per-column text slices for batch FZF scoring
colSlices := [3][]string{
make([]string, nItems),
make([]string, nItems),
make([]string, nItems),
}
for i, item := range h.all_items {
colSlices[0][i] = item.colTexts[0]
colSlices[1][i] = item.colTexts[1]
colSlices[2][i] = item.colTexts[2]
}
// Score each column independently
colResults := [3][]fzf.Result{}
for c := range 3 {
results, err := h.matcher.Score(colSlices[c], h.query)
if err == nil {
colResults[c] = results
}
}
type scored struct {
idx int
score uint
colIdx int
positions []int
}
var matches []scored
var matches []scoredItem
for i := range h.all_items {
if !h.show_unmapped && h.all_items[i].binding.Key == "" {
continue
}
bestScore := uint(0)
bestCol := 0
var bestPositions []int
for c := range 3 {
if colResults[c] != nil && i < len(colResults[c]) && colResults[c][i].Score > bestScore {
bestScore = colResults[c][i].Score
bestCol = c
bestPositions = colResults[c][i].Positions
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 bestScore > 0 {
matches = append(matches, scored{idx: i, score: bestScore, colIdx: bestCol, positions: bestPositions})
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 {
return matches[i].score > matches[j].score
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] = matchInfo{colIdx: m.colIdx, positions: m.positions}
h.match_infos[i] = m.mi
}
h.selected_idx = 0
h.scroll_offset = 0
@@ -377,16 +551,7 @@ func (h *Handler) draw_screen() {
// Draw help text for selected binding
h.lp.MoveCursorTo(1, helpY)
if b := h.selectedBinding(); b != nil && b.Help != "" {
helpStr := b.Help
maxLen := max(width-2, 3)
if wcswidth.Stringwidth(helpStr) > maxLen {
// Truncate by runes to avoid breaking multi-byte characters
runes := []rune(helpStr)
for len(runes) > 0 && wcswidth.Stringwidth(string(runes))+3 > maxLen {
runes = runes[:len(runes)-1]
}
helpStr = string(runes) + "..."
}
helpStr := truncateToWidth(b.Help, max(width-2, 3))
h.lp.QueueWriteString(h.lp.SprintStyled("dim italic", " "+helpStr))
}
@@ -400,11 +565,11 @@ func (h *Handler) draw_screen() {
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"
matchInfo := ""
matchCount := ""
if h.query != "" {
matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items))
matchCount = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items))
}
h.lp.QueueWriteString(" " + footer + h.lp.SprintStyled("dim", matchInfo))
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)
@@ -424,16 +589,11 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
lastMode = b.Mode
lastCategory = ""
if b.Mode != "" {
// Non-default mode: show "── Keyboard mode: name ──" header (purple), no category separators
if len(lines) > 0 {
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
}
label := "Keyboard mode: " + b.Mode
labelWidth := wcswidth.Stringwidth(label)
sepLen := max(0, width-labelWidth-6)
sep := strings.Repeat("\u2500", sepLen)
lines = append(lines, displayLine{
text: fmt.Sprintf(" \u2500\u2500 %s %s", label, sep),
text: sectionHeader("Keyboard mode: "+b.Mode, width),
isModeHdr: true, isHeader: true, itemIdx: -1,
})
}
@@ -445,21 +605,14 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
if len(lines) > 0 && !lines[len(lines)-1].isHeader {
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
}
catWidth := wcswidth.Stringwidth(b.Category)
sepLen := max(0, width-catWidth-6)
sep := strings.Repeat("\u2500", sepLen)
lines = append(lines, displayLine{
text: fmt.Sprintf(" \u2500\u2500 %s %s", b.Category, sep),
text: sectionHeader(b.Category, width),
isHeader: true, itemIdx: -1,
})
}
// Binding line — key column shows "(unmapped)" for actions with no shortcut
keyDisplay := b.Key
if keyDisplay == "" {
keyDisplay = unmappedLabel
}
keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth)
// Binding line
keyDisplay := keyDisplayText(b)
lines = append(lines, displayLine{
text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay),
itemIdx: fi,
@@ -482,11 +635,7 @@ func (h *Handler) drawFlatResults(startY, maxRows, width int) {
for fi, idx := range h.filtered_idx {
item := &h.all_items[idx]
b := &item.binding
keyDisplay := b.Key
if keyDisplay == "" {
keyDisplay = unmappedLabel
}
keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth)
keyDisplay := keyDisplayText(b)
catSuffix := ""
if b.Mode != "" {
catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)
@@ -549,37 +698,26 @@ func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) {
} 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
padded := text
textWidth := wcswidth.Stringwidth(text)
if textWidth < width {
padded += strings.Repeat(" ", width-textWidth)
}
h.lp.QueueWriteString(h.lp.SprintStyled("fg=black bg=white", padded))
// Selected item: highlight with reverse video, preserving match highlights
h.drawBindingLine(li.itemIdx, width, true)
} else {
h.drawBindingLine(text, li.itemIdx, width)
h.drawBindingLine(li.itemIdx, width, false)
}
}
}
func (h *Handler) drawBindingLine(text string, filteredIdx, width int) {
func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) {
if filteredIdx < 0 || filteredIdx >= len(h.filtered_idx) {
h.lp.QueueWriteString(text)
return
}
idx := h.filtered_idx[filteredIdx]
if idx < 0 || idx >= len(h.all_items) {
h.lp.QueueWriteString(text)
return
}
b := &h.all_items[idx].binding
// Build the key display (using unmappedLabel for items with no shortcut)
rawKey := b.Key
if rawKey == "" {
rawKey = unmappedLabel
}
keyDisplay := truncateToWidth(rawKey, maxKeyDisplayWidth)
// Build the key display
keyDisplay := keyDisplayText(b)
// Determine match info for highlighting (only set when a query is active)
var mi *matchInfo
@@ -587,50 +725,77 @@ func (h *Handler) drawBindingLine(text string, filteredIdx, width int) {
mi = &h.match_infos[filteredIdx]
}
const matchStyle = "fg=bright-yellow"
const keyStyle = "fg=green"
const unmappedStyle = "dim fg=green"
// 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))
if mi != nil && mi.colIdx == 0 {
ks := keyStyle
if b.Key == "" {
ks = unmappedStyle
}
h.lp.QueueWriteString(" ")
h.lp.QueueWriteString(h.highlightMatchedChars(keyDisplay, mi.positions, ks, matchStyle))
h.lp.QueueWriteString(strings.Repeat(" ", paddingLen) + " ")
} else if b.Key == "" {
h.lp.QueueWriteString(h.lp.SprintStyled(unmappedStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" "))
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(keyStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" "))
h.lp.QueueWriteString(h.lp.SprintStyled(ks, " "+keyDisplay+pad))
}
// Render action display column
if mi != nil && mi.colIdx == 1 {
h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.positions, "", matchStyle))
if mi != nil && len(mi.actionPositions) > 0 {
h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.actionPositions, baseStyle, matchStyle))
} else {
h.lp.QueueWriteString(b.ActionDisplay)
h.lp.QueueWriteString(styled(b.ActionDisplay))
}
// Render category suffix (only present in flat / search-results mode)
if h.query != "" {
if mi != nil && mi.colIdx == 2 {
if b.Mode != "" {
h.lp.QueueWriteString(fmt.Sprintf(" [%s/", b.Mode))
} else {
h.lp.QueueWriteString(" [")
}
h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.positions, "", matchStyle))
h.lp.QueueWriteString("]")
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 + "]"))
}
}
// For selected rows, pad the rest of the line with reverse video
if isSelected {
// Calculate rendered width and pad to fill the line
rendered := 4 + wcswidth.Stringwidth(keyDisplay) + paddingLen + 1 + wcswidth.Stringwidth(b.ActionDisplay)
if h.query != "" {
rendered += 2 + wcswidth.Stringwidth(b.Category) + 1
if b.Mode != "" {
h.lp.QueueWriteString(fmt.Sprintf(" [%s/%s]", b.Mode, b.Category))
} else {
h.lp.QueueWriteString(fmt.Sprintf(" [%s]", b.Category))
rendered += wcswidth.Stringwidth(b.Mode) + 1
}
}
if pad := width - rendered; pad > 0 {
h.lp.QueueWriteString(h.lp.SprintStyled(baseStyle, strings.Repeat(" ", pad)))
}
}
}

View File

@@ -5,53 +5,99 @@ import (
"fmt"
"strings"
"testing"
"github.com/kovidgoyal/kitty/tools/fzf"
)
func sampleInputJSON() string {
return `{
"modes": {
"": {
"Copy/paste": [
{"key": "ctrl+shift+c", "action": "copy_to_clipboard", "action_display": "copy_to_clipboard", "definition": "copy_to_clipboard", "help": "Copy the selected text from the active window to the clipboard", "long_help": ""},
{"key": "ctrl+shift+v", "action": "paste_from_clipboard", "action_display": "paste_from_clipboard", "definition": "paste_from_clipboard", "help": "Paste from the clipboard to the active window", "long_help": ""}
],
"Scrolling": [
{"key": "ctrl+shift+up", "action": "scroll_line_up", "action_display": "scroll_line_up", "definition": "scroll_line_up", "help": "Scroll up one line", "long_help": ""},
{"key": "ctrl+shift+down", "action": "scroll_line_down", "action_display": "scroll_line_down", "definition": "scroll_line_down", "help": "Scroll down one line", "long_help": ""}
],
"Window management": [
{"key": "ctrl+shift+enter", "action": "new_window", "action_display": "new_window", "definition": "new_window", "help": "Open a new window", "long_help": ""}
]
},
"mw": {
"Miscellaneous": [
{"key": "left", "action": "neighboring_window", "action_display": "neighboring_window left", "definition": "neighboring_window left", "help": "Focus neighbor window", "long_help": ""},
{"key": "esc", "action": "pop_keyboard_mode", "action_display": "pop_keyboard_mode", "definition": "pop_keyboard_mode", "help": "Pop keyboard mode", "long_help": ""}
]
}
// testBinding creates a Binding where Action, ActionDisplay, and Definition all
// equal action. Covers 90% of test bindings.
func testBinding(key, action, help string) Binding {
return Binding{
Key: key,
Action: action,
ActionDisplay: action,
Definition: action,
Help: help,
}
}
// testMouseBinding creates a mouse Binding where ActionDisplay differs from
// Action. Action is derived as the first word of actionDisplay.
func testMouseBinding(key, actionDisplay string) Binding {
action := actionDisplay
if idx := strings.IndexByte(actionDisplay, ' '); idx >= 0 {
action = actionDisplay[:idx]
}
return Binding{
Key: key,
Action: action,
ActionDisplay: actionDisplay,
Definition: actionDisplay,
}
}
// testHandlerBuilder constructs a Handler with programmatic data (no JSON round-trip).
type testHandlerBuilder struct {
input InputData
}
func newBuilder() *testHandlerBuilder {
return &testHandlerBuilder{
input: InputData{
Modes: make(map[string]map[string][]Binding),
CategoryOrder: make(map[string][]string),
},
"mouse": [
{"key": "left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection normal", "definition": "mouse_selection normal", "help": "", "long_help": ""},
{"key": "ctrl+left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection rectangle", "definition": "mouse_selection rectangle", "help": "", "long_help": ""}
],
"mode_order": ["", "mw"],
"category_order": {
"": ["Copy/paste", "Scrolling", "Window management"],
"mw": ["Miscellaneous"]
}
}`
}
}
func (b *testHandlerBuilder) addBinding(mode, category string, binding Binding) *testHandlerBuilder {
if b.input.Modes[mode] == nil {
b.input.Modes[mode] = make(map[string][]Binding)
b.input.ModeOrder = append(b.input.ModeOrder, mode)
}
cats := b.input.Modes[mode]
if _, ok := cats[category]; !ok {
b.input.CategoryOrder[mode] = append(b.input.CategoryOrder[mode], category)
}
cats[category] = append(cats[category], binding)
return b
}
func (b *testHandlerBuilder) addMouse(binding Binding) *testHandlerBuilder {
b.input.Mouse = append(b.input.Mouse, binding)
return b
}
func (b *testHandlerBuilder) build() *Handler {
h := &Handler{}
h.input_data = b.input
h.flattenBindings()
h.show_unmapped = true
return h
}
func newTestHandler() *Handler {
h := &Handler{}
if err := json.Unmarshal([]byte(sampleInputJSON()), &h.input_data); err != nil {
panic("test data JSON is invalid: " + err.Error())
}
h.flattenBindings()
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
return h
return newBuilder().
addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy the selected text from the active window to the clipboard")).
addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from the clipboard to the active window")).
addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up one line")).
addBinding("", "Scrolling", testBinding("ctrl+shift+down", "scroll_line_down", "Scroll down one line")).
addBinding("", "Window management", testBinding("ctrl+shift+enter", "new_window", "Open a new window")).
addBinding("mw", "Miscellaneous", Binding{
Key: "left", Action: "neighboring_window",
ActionDisplay: "neighboring_window left",
Definition: "neighboring_window left", Help: "Focus neighbor window",
}).
addBinding("mw", "Miscellaneous", testBinding("esc", "pop_keyboard_mode", "Pop keyboard mode")).
addMouse(Binding{
Key: "left press ungrabbed", Action: "mouse_selection",
ActionDisplay: "mouse_selection normal",
Definition: "mouse_selection normal",
}).
addMouse(Binding{
Key: "ctrl+left press ungrabbed", Action: "mouse_selection",
ActionDisplay: "mouse_selection rectangle",
Definition: "mouse_selection rectangle",
}).
build()
}
func TestFlattenAllBindings(t *testing.T) {
@@ -156,14 +202,12 @@ func TestFilterMatchesSubset(t *testing.T) {
// Verify all returned items contain relevant text in at least one column
for _, idx := range h.filtered_idx {
item := &h.all_items[idx]
found := false
for _, col := range item.colTexts {
if strings.Contains(strings.ToLower(col), "clipboard") {
found = true
break
}
found := strings.Contains(strings.ToLower(item.keyText), "clipboard") ||
strings.Contains(strings.ToLower(item.actionText), "clipboard") ||
strings.Contains(strings.ToLower(item.categoryText), "clipboard")
if !found {
t.Fatalf("Matched item %q has no column containing 'clipboard'", item.actionText)
}
_ = found // FZF does fuzzy matching, so this is a soft check
}
}
@@ -239,16 +283,16 @@ func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) {
func TestSearchTextContainsKeyAndAction(t *testing.T) {
h := newTestHandler()
for i, item := range h.all_items {
// colTexts[0] = key (or unmappedLabel for empty key), [1] = action_display, [2] = category
// keyText = key (or unmappedLabel for empty key), actionText = action_display, categoryText = category
expectedKey := item.binding.Key
if expectedKey == "" {
expectedKey = unmappedLabel
}
if !strings.Contains(item.colTexts[0], expectedKey) {
t.Fatalf("Item %d: colTexts[0] %q should contain key %q", i, item.colTexts[0], expectedKey)
if !strings.Contains(item.keyText, expectedKey) {
t.Fatalf("Item %d: keyText %q should contain key %q", i, item.keyText, expectedKey)
}
if !strings.Contains(item.colTexts[1], item.binding.ActionDisplay) {
t.Fatalf("Item %d: colTexts[1] %q should contain action %q", i, item.colTexts[1], item.binding.ActionDisplay)
if !strings.Contains(item.actionText, item.binding.ActionDisplay) {
t.Fatalf("Item %d: actionText %q should contain action %q", i, item.actionText, item.binding.ActionDisplay)
}
}
}
@@ -271,13 +315,7 @@ func TestHelpTextPreserved(t *testing.T) {
}
func TestEmptyInputData(t *testing.T) {
h := &Handler{}
emptyJSON := `{"modes": {}, "mouse": [], "mode_order": [], "category_order": {}}`
if err := json.Unmarshal([]byte(emptyJSON), &h.input_data); err != nil {
t.Fatal(err)
}
h.flattenBindings()
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
h := newBuilder().build()
h.updateFilter()
if len(h.all_items) != 0 {
@@ -560,7 +598,7 @@ func TestScrollAdjustRevealsSectionHeader(t *testing.T) {
}
}
func TestColTextsPopulated(t *testing.T) {
func TestDisplayItemFieldsPopulated(t *testing.T) {
h := newTestHandler()
for i, item := range h.all_items {
if item.binding.IsMouse {
@@ -570,14 +608,14 @@ func TestColTextsPopulated(t *testing.T) {
if expectedKey == "" {
expectedKey = unmappedLabel
}
if item.colTexts[0] != expectedKey {
t.Fatalf("Item %d: colTexts[0]=%q expected %q", i, item.colTexts[0], expectedKey)
if item.keyText != expectedKey {
t.Fatalf("Item %d: keyText=%q expected %q", i, item.keyText, expectedKey)
}
if item.colTexts[1] != item.binding.ActionDisplay {
t.Fatalf("Item %d: colTexts[1]=%q expected %q", i, item.colTexts[1], item.binding.ActionDisplay)
if item.actionText != item.binding.ActionDisplay {
t.Fatalf("Item %d: actionText=%q expected %q", i, item.actionText, item.binding.ActionDisplay)
}
if item.colTexts[2] != item.binding.Category {
t.Fatalf("Item %d: colTexts[2]=%q expected %q", i, item.colTexts[2], item.binding.Category)
if item.categoryText != item.binding.Category {
t.Fatalf("Item %d: categoryText=%q expected %q", i, item.categoryText, item.binding.Category)
}
}
}
@@ -594,14 +632,12 @@ func TestFilterSingleColumnMatch(t *testing.T) {
// All matched items should have 'scroll' in exactly one column, not spread across columns
for _, idx := range h.filtered_idx {
item := &h.all_items[idx]
colMatch := false
for _, col := range item.colTexts {
if strings.Contains(strings.ToLower(col), "scroll") {
colMatch = true
break
}
colMatch := strings.Contains(strings.ToLower(item.keyText), "scroll") ||
strings.Contains(strings.ToLower(item.actionText), "scroll") ||
strings.Contains(strings.ToLower(item.categoryText), "scroll")
if !colMatch {
t.Fatalf("Matched item %q has no column containing 'scroll'", item.actionText)
}
_ = colMatch // FZF does fuzzy matching; at least the characters should appear in one column
}
}
@@ -616,8 +652,8 @@ func TestFilterMatchInfosParallelToFilteredIdx(t *testing.T) {
t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx))
}
for i, mi := range h.match_infos {
if mi.colIdx < 0 || mi.colIdx > 2 {
t.Fatalf("match_infos[%d].colIdx=%d out of range [0,2]", i, mi.colIdx)
if len(mi.keyPositions) == 0 && len(mi.actionPositions) == 0 && len(mi.categoryPositions) == 0 {
t.Fatalf("match_infos[%d] has no positions in any column", i)
}
}
}
@@ -633,25 +669,10 @@ func TestFilterMatchInfosNilWhenNoQuery(t *testing.T) {
func TestUnmappedActionDisplayed(t *testing.T) {
// Inject an item with an empty key (unmapped action) and verify display
h := &Handler{}
unmappedJSON := `{
"modes": {
"": {
"Miscellaneous": [
{"key": "ctrl+n", "action": "new_window", "action_display": "new_window", "definition": "new_window", "help": "Open new window", "long_help": ""},
{"key": "", "action": "scroll_home", "action_display": "scroll_home", "definition": "scroll_home", "help": "Scroll to top", "long_help": ""}
]
}
},
"mouse": [],
"mode_order": [""],
"category_order": {"": ["Miscellaneous"]}
}`
if err := json.Unmarshal([]byte(unmappedJSON), &h.input_data); err != nil {
t.Fatal(err)
}
h.flattenBindings()
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
h := newBuilder().
addBinding("", "Miscellaneous", testBinding("ctrl+n", "new_window", "Open new window")).
addBinding("", "Miscellaneous", testBinding("", "scroll_home", "Scroll to top")).
build()
if len(h.all_items) != 2 {
t.Fatalf("Expected 2 items, got %d", len(h.all_items))
@@ -667,9 +688,9 @@ func TestUnmappedActionDisplayed(t *testing.T) {
if unmapped == nil {
t.Fatal("Expected to find unmapped item")
}
// colTexts[0] should be unmappedLabel, not empty
if unmapped.colTexts[0] != unmappedLabel {
t.Fatalf("Expected colTexts[0]=%q for unmapped item, got %q", unmappedLabel, unmapped.colTexts[0])
// keyText should be unmappedLabel, not empty
if unmapped.keyText != unmappedLabel {
t.Fatalf("Expected keyText=%q for unmapped item, got %q", unmappedLabel, unmapped.keyText)
}
// With show_unmapped=true, unmapped action should be searchable
@@ -694,25 +715,11 @@ func TestUnmappedActionDisplayed(t *testing.T) {
func TestShowUnmappedToggle(t *testing.T) {
// TestShowUnmappedToggle creates a handler with both mapped and unmapped items
// and verifies that the show_unmapped flag correctly filters the display.
h := &Handler{}
mixedJSON := `{
"modes": {
"": {
"Copy/paste": [
{"key": "ctrl+c", "action": "copy", "action_display": "copy", "definition": "copy", "help": "Copy", "long_help": ""},
{"key": "", "action": "paste_from_buffer", "action_display": "paste_from_buffer", "definition": "paste_from_buffer", "help": "Paste from buffer", "long_help": ""}
]
}
},
"mouse": [],
"mode_order": [""],
"category_order": {"": ["Copy/paste"]}
}`
if err := json.Unmarshal([]byte(mixedJSON), &h.input_data); err != nil {
t.Fatal(err)
}
h.flattenBindings()
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
h := newBuilder().
addBinding("", "Copy/paste", testBinding("ctrl+c", "copy", "Copy")).
addBinding("", "Copy/paste", testBinding("", "paste_from_buffer", "Paste from buffer")).
build()
h.show_unmapped = false // override default from build()
if len(h.all_items) != 2 {
t.Fatalf("Expected 2 items in all_items, got %d", len(h.all_items))
@@ -745,3 +752,644 @@ func TestShowUnmappedToggle(t *testing.T) {
}
}
}
// newMultiTokenTestHandler creates a handler with items designed to test
// multi-token search. It has items where different tokens match different
// columns, enabling cross-column and RRF ranking tests.
func newMultiTokenTestHandler() *Handler {
return newBuilder().
addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy selected text")).
addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from clipboard")).
addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up one line")).
addBinding("", "Scrolling", testBinding("ctrl+shift+down", "scroll_line_down", "Scroll down one line")).
addBinding("", "Scrolling", testBinding("ctrl+shift+page_up", "scroll_page_up", "Scroll up one page")).
addBinding("", "Scrolling", testBinding("ctrl+shift+home", "scroll_home", "Scroll to top")).
addBinding("", "Window management", testBinding("ctrl+shift+enter", "new_window", "Open a new window")).
addBinding("", "Window management", testBinding("ctrl+shift+w", "close_window", "Close the active window")).
addBinding("", "Tab management", testBinding("ctrl+shift+t", "new_tab", "Open a new tab")).
addBinding("", "Tab management", testBinding("ctrl+shift+q", "close_tab", "Close the active tab")).
build()
}
func TestMultiTokenAllMatchRanksAbovePartial(t *testing.T) {
// An item matching ALL tokens should rank above an item matching only SOME tokens.
// "scroll up" should rank scroll_line_up and scroll_page_up (both tokens match)
// above scroll_home or scroll_line_down (only "scroll" matches).
h := newMultiTokenTestHandler()
h.query = "scroll up"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for 'scroll up'")
}
// Items matching both "scroll" and "up" should appear before items matching only one token.
// scroll_line_up and scroll_page_up match both; scroll_line_down and scroll_home match only "scroll".
topResults := make([]string, 0)
for i, idx := range h.filtered_idx {
action := h.all_items[idx].binding.ActionDisplay
if i < 2 {
topResults = append(topResults, action)
}
}
for _, action := range topResults {
if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") {
t.Fatalf("Top result %q should match both 'scroll' and 'up'", action)
}
}
}
func TestMultiTokenCrossColumnMatch(t *testing.T) {
// A query with tokens that match different columns should find the item.
// "ctrl+shift+c copy" — "ctrl+shift+c" matches the key column,
// "copy" matches the action column.
h := newMultiTokenTestHandler()
h.query = "ctrl+shift+c copy"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for cross-column query 'ctrl+shift+c copy'")
}
// copy_to_clipboard (key=ctrl+shift+c, action=copy_to_clipboard) should be the top result
topAction := h.all_items[h.filtered_idx[0]].binding.ActionDisplay
if topAction != "copy_to_clipboard" {
t.Fatalf("Expected top result to be 'copy_to_clipboard', got %q", topAction)
}
}
func TestMultiTokenCrossColumnCategoryMatch(t *testing.T) {
// A token matching the category column combined with a token matching the action column.
// "window close" — "window" matches category "Window management",
// "close" matches action "close_window".
h := newMultiTokenTestHandler()
h.query = "window close"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for 'window close'")
}
// close_window should rank highly since both tokens match
found := false
for _, idx := range h.filtered_idx[:min(3, len(h.filtered_idx))] {
if h.all_items[idx].binding.ActionDisplay == "close_window" {
found = true
break
}
}
if !found {
t.Fatal("Expected 'close_window' in top results for 'window close'")
}
}
func TestMultiTokenExtraWhitespace(t *testing.T) {
// Extra whitespace in the query should not produce empty/ghost tokens.
// " scroll up " should behave the same as "scroll up".
h := newMultiTokenTestHandler()
h.query = "scroll up"
h.updateFilter()
normalResults := make([]int, len(h.filtered_idx))
copy(normalResults, h.filtered_idx)
h.query = " scroll up "
h.updateFilter()
if len(h.filtered_idx) != len(normalResults) {
t.Fatalf("Extra whitespace changed result count: %d vs %d", len(h.filtered_idx), len(normalResults))
}
for i := range h.filtered_idx {
if h.filtered_idx[i] != normalResults[i] {
t.Fatalf("Extra whitespace changed result order at position %d", i)
}
}
}
func TestMultiTokenAllWhitespaceIsEmptyQuery(t *testing.T) {
// A query that is only whitespace should behave like an empty query:
// return all items in original order with no match_infos.
h := newMultiTokenTestHandler()
h.query = " "
h.updateFilter()
if len(h.filtered_idx) != len(h.all_items) {
t.Fatalf("All-whitespace query should return all %d items, got %d", len(h.all_items), len(h.filtered_idx))
}
if h.match_infos != nil {
t.Fatal("All-whitespace query should produce nil match_infos")
}
}
func TestMultiTokenSingleTokenRegression(t *testing.T) {
// A single-token query (no spaces) should produce the same results as before.
h := newMultiTokenTestHandler()
h.query = "clipboard"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for single token 'clipboard'")
}
// All results should have "clipboard" matched in at least one column
for _, idx := range h.filtered_idx {
item := &h.all_items[idx]
anyMatch := strings.Contains(strings.ToLower(item.keyText), "clipboard") ||
strings.Contains(strings.ToLower(item.actionText), "clipboard") ||
strings.Contains(strings.ToLower(item.categoryText), "clipboard")
if !anyMatch {
t.Fatalf("Matched item %q has no column containing 'clipboard'", item.actionText)
}
}
// match_infos should still be parallel to filtered_idx
if len(h.match_infos) != len(h.filtered_idx) {
t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx))
}
}
func TestMultiTokenNoMatchReturnsEmpty(t *testing.T) {
// When no item matches any token, the result should be empty.
h := newMultiTokenTestHandler()
h.query = "zzzzz xxxxx"
h.updateFilter()
if len(h.filtered_idx) != 0 {
t.Fatalf("Expected no matches for nonsense multi-token query, got %d", len(h.filtered_idx))
}
}
func TestMultiTokenPartialMatchStillShown(t *testing.T) {
// Items matching only some tokens should still appear,
// but ranked lower than items matching all tokens.
h := newMultiTokenTestHandler()
h.query = "scroll zzzznonexistent"
h.updateFilter()
// "scroll" matches several items, "zzzznonexistent" matches nothing.
// Items matching "scroll" should still appear.
if len(h.filtered_idx) == 0 {
t.Fatal("Expected partial matches when one token matches and one doesn't")
}
// Verify that matched items are related to "scroll"
for _, idx := range h.filtered_idx {
item := &h.all_items[idx]
anyMatch := strings.Contains(strings.ToLower(item.keyText), "scroll") ||
strings.Contains(strings.ToLower(item.actionText), "scroll") ||
strings.Contains(strings.ToLower(item.categoryText), "scroll")
if !anyMatch {
t.Fatalf("Matched item %q has no column containing 'scroll'", item.actionText)
}
}
}
func TestMultiTokenMatchInfosTrackMultipleColumns(t *testing.T) {
// When tokens match different columns, match_infos should reflect
// positions in each matched column so highlighting works correctly.
h := newMultiTokenTestHandler()
h.query = "tab close"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for 'tab close'")
}
if len(h.match_infos) != len(h.filtered_idx) {
t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx))
}
// Find close_tab — "tab" matches category "Tab management" and action "close_tab",
// "close" matches action "close_tab". match_infos must have positions in multiple columns.
for fi, idx := range h.filtered_idx {
if h.all_items[idx].binding.ActionDisplay == "close_tab" {
mi := h.match_infos[fi]
if len(mi.actionPositions) == 0 {
t.Fatal("close_tab: expected match positions in action column")
}
if len(mi.categoryPositions) == 0 {
t.Fatal("close_tab: expected match positions in category column for 'tab' in 'Tab management'")
}
return
}
}
t.Fatal("Expected close_tab in results for 'tab close'")
}
func TestMultiTokenOrderIndependence(t *testing.T) {
// Token order should not matter: "close window" and "window close"
// should produce the same set of results (possibly in different order,
// but the same items).
h := newMultiTokenTestHandler()
h.query = "window close"
h.updateFilter()
results1 := make(map[int]bool)
for _, idx := range h.filtered_idx {
results1[idx] = true
}
h.query = "close window"
h.updateFilter()
results2 := make(map[int]bool)
for _, idx := range h.filtered_idx {
results2[idx] = true
}
if len(results1) != len(results2) {
t.Fatalf("Token order changed result count: %d vs %d", len(results1), len(results2))
}
for idx := range results1 {
if !results2[idx] {
t.Fatalf("Item %d present in 'window close' but not 'close window'", idx)
}
}
}
// topActions returns the action_display names of the first n results after
// running query on h. It is a test helper for verifying ranking.
func topActions(h *Handler, query string, n int) []string {
h.query = query
h.updateFilter()
var result []string
for i, idx := range h.filtered_idx {
if i >= n {
break
}
result = append(result, h.all_items[idx].binding.ActionDisplay)
}
return result
}
func TestQueryRankingScrollUp(t *testing.T) {
h := newMultiTokenTestHandler()
top := topActions(h, "scroll up", 4)
if len(top) < 4 {
t.Fatalf("Expected at least 4 results for 'scroll up', got %d", len(top))
}
// Top 2 should match both "scroll" and "up", with scroll_line_up first (shorter)
for _, action := range top[:2] {
if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") {
t.Fatalf("Top result %q should match both 'scroll' and 'up'", action)
}
}
if top[0] != "scroll_line_up" {
t.Fatalf("Expected scroll_line_up first, got %q", top[0])
}
if top[1] != "scroll_page_up" {
t.Fatalf("Expected scroll_page_up second, got %q", top[1])
}
// Items matching only "scroll" (not "up") should rank below
for _, action := range top[2:] {
if strings.Contains(action, "up") {
continue // other "up" matches are fine here
}
if !strings.Contains(action, "scroll") {
t.Fatalf("Lower result %q should still contain 'scroll'", action)
}
}
}
func TestQueryRankingNewWindow(t *testing.T) {
h := newMultiTokenTestHandler()
top := topActions(h, "new window", 3)
if len(top) == 0 {
t.Fatal("Expected results for 'new window'")
}
if top[0] != "new_window" {
t.Fatalf("Expected new_window first, got %q", top[0])
}
// close_window should not appear above new_window
for i, action := range top {
if action == "close_window" && i == 0 {
t.Fatal("close_window should not be the top result for 'new window'")
}
}
}
func TestQueryRankingCloseTab(t *testing.T) {
h := newMultiTokenTestHandler()
top := topActions(h, "close tab", 3)
if len(top) == 0 {
t.Fatal("Expected results for 'close tab'")
}
if top[0] != "close_tab" {
t.Fatalf("Expected close_tab first, got %q", top[0])
}
}
func TestQueryRankingSingleToken(t *testing.T) {
h := newMultiTokenTestHandler()
top := topActions(h, "clipboard", 2)
if len(top) < 2 {
t.Fatalf("Expected at least 2 results for 'clipboard', got %d", len(top))
}
// copy_to_clipboard is shorter than paste_from_clipboard
if top[0] != "copy_to_clipboard" {
t.Fatalf("Expected copy_to_clipboard first, got %q", top[0])
}
if top[1] != "paste_from_clipboard" {
t.Fatalf("Expected paste_from_clipboard second, got %q", top[1])
}
}
func TestQueryRankingExactActionMatch(t *testing.T) {
h := newMultiTokenTestHandler()
top := topActions(h, "new_tab", 1)
if len(top) == 0 {
t.Fatal("Expected results for 'new_tab'")
}
if top[0] != "new_tab" {
t.Fatalf("Expected new_tab first, got %q", top[0])
}
}
// newMouseTestHandler creates a handler with realistic mouse bindings matching
// the actual kitty command palette data, to test ranking of mouse_selection queries.
// Includes keyboard bindings with "selection" in their names to ensure mouse_selection
// items rank above them for the query "mouse selection".
func newMouseTestHandler() *Handler {
return newBuilder().
addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up")).
addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy selected text")).
addBinding("", "Copy/paste", testBinding("shift+insert", "paste_selection", "Paste from primary selection")).
addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from clipboard")).
addBinding("", "Copy/paste", testBinding("", "copy_or_interrupt", "Copy selection or interrupt")).
addBinding("", "Copy/paste", testBinding("", "copy_and_clear_or_interrupt", "Copy selection and clear")).
addBinding("", "Copy/paste", testBinding("", "pass_selection_to_program", "Pass selection to program")).
addMouse(testMouseBinding("shift+left click ungrabbed", "mouse_handle_click selection link prompt")).
addMouse(testMouseBinding("shift+left click grabbed ungrabbed", "mouse_handle_click selection link prompt")).
addMouse(testMouseBinding("ctrl+shift+left release grabbed ungrabbed", "mouse_handle_click link")).
addMouse(testMouseBinding("shift+middle release ungrabbed grabbed", "paste_selection")).
addMouse(testMouseBinding("middle release ungrabbed", "paste_from_selection")).
addMouse(testMouseBinding("left drag ungrabbed", "mouse_selection")).
addMouse(testMouseBinding("shift+left drag ungrabbed", "mouse_selection")).
addMouse(testMouseBinding("left triplepress ungrabbed", "mouse_selection line")).
addMouse(testMouseBinding("shift+left doublepress ungrabbed", "mouse_selection word")).
addMouse(testMouseBinding("shift+left triplepress ungrabbed", "mouse_selection line_from_point")).
addMouse(testMouseBinding("shift+left triplepress+grabbed", "mouse_selection line_from_point")).
addMouse(testMouseBinding("right press ungrabbed", "mouse_selection extend")).
addMouse(testMouseBinding("shift+left press ungrabbed", "mouse_selection extend")).
addMouse(testMouseBinding("left press ungrabbed", "mouse_selection normal")).
addMouse(testMouseBinding("ctrl+left press ungrabbed", "mouse_selection rectangle")).
addMouse(testMouseBinding("ctrl+shift+right press ungrabbed", "mouse_selection rectangle extend")).
addMouse(testMouseBinding("ctrl+shift+left press ungrabbed", "mouse_selection rectangle extend")).
addMouse(testMouseBinding("shift+left triplepress", "mouse_selection line_from_point")).
addMouse(testMouseBinding("left press", "mouse_selection normal")).
build()
}
// searchResult captures the full display state of a single result row:
// all three visible columns plus which columns have highlighted match positions.
type searchResult struct {
key string // key binding
action string // action_display
category string // category
// Which columns have highlighted (matched) character positions.
keyMatch bool
actionMatch bool
categoryMatch bool
}
// queryResults runs query on h and returns the full display state of each result.
func queryResults(h *Handler, query string) []searchResult {
h.query = query
h.updateFilter()
results := make([]searchResult, len(h.filtered_idx))
for i, idx := range h.filtered_idx {
item := &h.all_items[idx]
results[i] = searchResult{
key: item.keyText,
action: item.actionText,
category: item.categoryText,
keyMatch: len(h.match_infos[i].keyPositions) > 0,
actionMatch: len(h.match_infos[i].actionPositions) > 0,
categoryMatch: len(h.match_infos[i].categoryPositions) > 0,
}
}
return results
}
func TestQueryRankingMouseSelection(t *testing.T) {
h := newMouseTestHandler()
results := queryResults(h, "mouse selection")
if len(results) == 0 {
t.Fatal("Expected results for 'mouse selection'")
}
// Bare "mouse_selection" (shortest, exact match for both tokens) must rank
// above all suffixed variants like mouse_selection line/word/extend/normal.
if results[0].action != "mouse_selection" {
t.Fatalf("Expected bare 'mouse_selection' first, got %q", results[0].action)
}
// All mouse_selection variants (action starts with "mouse_selection") must
// rank above any non-mouse_selection item.
lastMouseSelection := -1
firstOther := -1
for i, r := range results {
if strings.HasPrefix(r.action, "mouse_selection") {
lastMouseSelection = i
} else if firstOther == -1 {
firstOther = i
}
}
if firstOther != -1 && firstOther < lastMouseSelection {
t.Fatalf("Non-mouse_selection item %q at position %d ranks above mouse_selection item at position %d",
results[firstOther].action, firstOther+1, lastMouseSelection+1)
}
// Every mouse_selection result must have action column highlighted (both
// "mouse" and "selection" appear in the action text).
for i, r := range results {
if !strings.HasPrefix(r.action, "mouse_selection") {
continue
}
if !r.actionMatch {
t.Fatalf("Result %d (%s): mouse_selection item must have action column highlighted", i+1, r.action)
}
}
// mouse_handle_click also matches both "mouse" and "selection" in its action
// text, but it's a longer string so it should rank below mouse_selection items.
for i, r := range results {
if strings.HasPrefix(r.action, "mouse_handle_click") {
if i < lastMouseSelection {
t.Fatalf("Result %d (%s): should rank below all mouse_selection variants (last at %d)",
i+1, r.action, lastMouseSelection+1)
}
}
}
}
func TestQueryRankingMouseSelectionSingleToken(t *testing.T) {
h := newMouseTestHandler()
results := queryResults(h, "mouse")
if len(results) == 0 {
t.Fatal("Expected results for 'mouse'")
}
// Bare "mouse_selection" (shortest action with "mouse") should be first
if results[0].action != "mouse_selection" {
t.Fatalf("Expected bare 'mouse_selection' first, got %q", results[0].action)
}
// Items matching only via category (paste_selection, paste_from_selection)
// should rank below items matching "mouse" in the action column.
lastActionMatch := -1
for i, r := range results {
if r.actionMatch {
lastActionMatch = i
}
}
for i, r := range results {
if !r.actionMatch && r.categoryMatch && i < lastActionMatch {
t.Fatalf("Result %d (%s): category-only match should rank below action matches", i+1, r.action)
}
}
}
func TestQueryRankingShorterMatchFirst(t *testing.T) {
h := newMouseTestHandler()
// "mouse_selection normal" (shorter) should rank above "mouse_selection rectangle" (longer)
// when both match equally well
top := topActions(h, "mouse_selection normal", 1)
if len(top) == 0 {
t.Fatal("Expected results")
}
if top[0] != "mouse_selection normal" {
t.Fatalf("Expected 'mouse_selection normal' first, got %q", top[0])
}
}
func TestQueryMatchInfoColumns(t *testing.T) {
// Verify match_infos correctly tracks positions in all 3 columns: key, action, category.
h := newMultiTokenTestHandler()
// "ctrl clipboard" — "ctrl" matches key column (ctrl+shift+c), "clipboard" matches action
h.query = "ctrl clipboard"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for 'ctrl clipboard'")
}
// Find copy_to_clipboard in results
for fi, idx := range h.filtered_idx {
if h.all_items[idx].binding.ActionDisplay != "copy_to_clipboard" {
continue
}
mi := h.match_infos[fi]
// Key column (col 0) should have positions for "ctrl"
if len(mi.keyPositions) == 0 {
t.Fatal("copy_to_clipboard: expected match positions in key column for 'ctrl'")
}
// Action column (col 1) should have positions for "clipboard"
if len(mi.actionPositions) == 0 {
t.Fatal("copy_to_clipboard: expected match positions in action column for 'clipboard'")
}
return
}
t.Fatal("Expected copy_to_clipboard in results")
}
func TestQueryMatchInfoCategoryColumn(t *testing.T) {
// Verify the category column (col 2) gets match positions when a token matches it.
h := newMultiTokenTestHandler()
// "tab close" — "tab" matches category "Tab management", "close" matches action close_tab
h.query = "tab close"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for 'tab close'")
}
for fi, idx := range h.filtered_idx {
if h.all_items[idx].binding.ActionDisplay != "close_tab" {
continue
}
mi := h.match_infos[fi]
// Action column (col 1) should have positions for "close" and/or "tab"
if len(mi.actionPositions) == 0 {
t.Fatal("close_tab: expected match positions in action column")
}
// Category column (col 2) should have positions for "tab" in "Tab management"
if len(mi.categoryPositions) == 0 {
t.Fatal("close_tab: expected match positions in category column for 'tab'")
}
return
}
t.Fatal("Expected close_tab in results")
}
func TestQueryMatchInfoKeyColumn(t *testing.T) {
// Verify the key column (col 0) gets match positions when searching by key binding.
h := newMouseTestHandler()
// "left press" — matches key column for mouse bindings
h.query = "left press"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for 'left press'")
}
// At least one result should have positions in the key column
foundKeyMatch := false
for fi := range h.filtered_idx {
mi := h.match_infos[fi]
if len(mi.keyPositions) > 0 {
foundKeyMatch = true
break
}
}
if !foundKeyMatch {
t.Fatal("Expected at least one result with match positions in key column for 'left press'")
}
}
func TestQueryRankingShorterActionFirst(t *testing.T) {
// When 2 tokens both match in the action column of item A,
// A should rank above item B that also matches both tokens but has a
// longer action string. This verifies that shorter matches are preferred.
h := newBuilder().
addBinding("", "Window management", testBinding("ctrl+n", "new_window", "Open a new window")).
addBinding("", "Window management", testBinding("ctrl+w", "close_active", "Close the active pane")).
addBinding("", "Miscellaneous", testBinding("ctrl+shift+n", "new_os_window", "Open new OS window")).
build()
// "new window" — both tokens match new_window's action coherently,
// while new_os_window also matches but is longer.
top := topActions(h, "new window", 2)
if len(top) < 2 {
t.Fatalf("Expected at least 2 results, got %d", len(top))
}
// new_window should beat new_os_window (shorter action string)
if top[0] != "new_window" {
t.Fatalf("Expected new_window first (shorter match), got %q", top[0])
}
}
func TestQueryRankingCrossColumnVsCategoryOnly(t *testing.T) {
// An item matching tokens across key+action columns should rank above
// an item that only matches via the category column.
h := newBuilder().
addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up")).
addBinding("", "Scrolling", testBinding("page_up", "scroll_page_up", "Scroll one page up")).
addBinding("", "Scroll buffer", Binding{
Key: "ctrl+l", Action: "clear_terminal",
ActionDisplay: "clear_terminal reset active",
Definition: "clear_terminal reset active", Help: "Clear screen",
}).
build()
// "scroll up" — scroll_line_up and scroll_page_up match both tokens in action;
// clear_terminal only matches "scroll" via its category "Scroll buffer".
top := topActions(h, "scroll up", 3)
if len(top) < 2 {
t.Fatalf("Expected at least 2 results, got %d", len(top))
}
// Both scroll_*_up actions should rank above clear_terminal
for i, action := range top[:2] {
if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") {
t.Fatalf("Result %d: expected scroll_*_up variant, got %q", i+1, action)
}
}
// If clear_terminal appears, it should be last
for i, action := range top {
if action == "clear_terminal" && i < 2 {
t.Fatalf("clear_terminal (category-only match) should rank below action matches, but got position %d", i+1)
}
}
}