// License: GPLv3 Copyright: 2024, Kovid Goyal package command_palette import ( "encoding/json" "fmt" "io" "os" "sort" "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" "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"` 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"` } // 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 } // matchInfo stores which column matched and the matched character positions type matchInfo struct { colIdx int // which column matched: 0=key, 1=action_display, 2=category positions []int // rune positions in the matched column text } 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) + "..." } // CachedSettings holds persistent UI settings stored in command-palette.json. type CachedSettings struct { ShowUnmapped bool `json:"show_unmapped"` } 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 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] } 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 if err := h.loadData(); err != nil { return "", err } h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) h.updateFilter() h.draw_screen() h.lp.SendOverlayReady() return "", nil } 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 } // 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. 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 } for _, b := range bindings { b.Category = catName b.Mode = modeName b.IsMouse = false keyText := b.Key if keyText == "" { keyText = unmappedLabel } h.all_items = append(h.all_items, DisplayItem{ binding: b, colTexts: [3]string{keyText, b.ActionDisplay, catName}, }) } } } // 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, DisplayItem{ binding: b, colTexts: [3]string{b.Key, b.ActionDisplay, "Mouse actions"}, }) } } func (h *Handler) updateFilter() { if h.query == "" { // 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 } 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 := 0; c < 3; c++ { 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 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 := 0; c < 3; c++ { 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 } } if bestScore > 0 { matches = append(matches, scored{idx: i, score: bestScore, colIdx: bestCol, positions: bestPositions}) } } sort.Slice(matches, func(i, j int) bool { return matches[i].score > matches[j].score }) 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.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() } 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 } 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 := helpY - resultsStartY if resultsHeight < 1 { resultsHeight = 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 := b.Help maxLen := width - 2 if maxLen < 3 { maxLen = 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) + "..." } 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" matchInfo := "" if h.query != "" { matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items)) } h.lp.QueueWriteString(" " + footer + h.lp.SprintStyled("dim", matchInfo)) // Position cursor at end of search text for typing h.lp.MoveCursorTo(3+wcswidth.Stringwidth(h.query), searchBarY) } 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 != "" { // 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), 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}) } 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), 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) lines = append(lines, displayLine{ text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay), itemIdx: fi, }) } h.display_lines = lines h.drawLines(lines, startY, maxRows, width) } 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 := b.Key if keyDisplay == "" { keyDisplay = unmappedLabel } keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth) 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, b.ActionDisplay, catSuffix), itemIdx: fi, }) } h.display_lines = lines h.drawLines(lines, startY, maxRows, width) } 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 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)) } else { h.drawBindingLine(text, li.itemIdx, width) } } } func (h *Handler) drawBindingLine(text string, filteredIdx, width int) { 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) // 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] } const matchStyle = "fg=bright-yellow" const keyStyle = "fg=green" const unmappedStyle = "dim fg=green" // 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)+" ")) } else { h.lp.QueueWriteString(h.lp.SprintStyled(keyStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" ")) } // Render action display column if mi != nil && mi.colIdx == 1 { h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.positions, "", matchStyle)) } else { h.lp.QueueWriteString(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("]") } else { if b.Mode != "" { h.lp.QueueWriteString(fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)) } else { h.lp.QueueWriteString(fmt.Sprintf(" [%s]", b.Category)) } } } } // 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 } 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 } 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.MatchesPressOrRepeat("ctrl+k") || ev.MatchesPressOrRepeat("ctrl+p") { ev.Handled = true h.moveSelection(-1) return nil } if ev.MatchesPressOrRepeat("down") || ev.MatchesPressOrRepeat("ctrl+j") || ev.MatchesPressOrRepeat("ctrl+n") { ev.Handled = true 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 } 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 } func (h *Handler) onResize(old, new_size loop.ScreenSize) error { h.screen_size = new_size h.draw_screen() return nil } 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() } func (h *Handler) triggerSelected() { b := h.selectedBinding() if b == nil || b.IsMouse { h.lp.Beep() return } h.result = b.Definition h.lp.Quit(0) } 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 a mapping 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 } func EntryPoint(parent *cli.Command) { create_cmd(parent, main) }