Go 141 lines
package tui
import (
"fmt"
"strings"
)
// palette is the slash-command dropdown state. Typing "/" opens an instant,
// navigable list of the slash commands, each with its one-line purpose —
// the terminal command palette the operator targeted. The open/closed
// state is derived from the current input (see paletteOpen), so the cursor
// is the only stored palette state.
type palette struct {
cursor int // selected row within the current filtered set
}
// paletteMaxRows caps the visible dropdown height; the remainder is folded
// into a "+N more" line. Nine commands rarely overflow, so this is mostly
// defensive against a future-growing commandIndex.
const paletteMaxRows = 8
// paletteOpen reports whether the slash-command palette should show. It is
// open exactly while the input is a command token still being typed: a
// leading slash with no space yet. This reuses the command-vs-argument
// boundary complete() already relies on (commands.go) — "/" and "/me" open;
// "/run " (a committed command plus a space) closes into argument entry;
// empty or plain text never opens. A space, newline, or tab in the value is
// free text (a multi-line draft is never a command token), so any of them
// closes the palette.
func (m model) paletteOpen() bool {
v := m.ta.Value()
return strings.HasPrefix(v, "/") && !strings.ContainsAny(v, " \n\t")
}
// paletteItems returns the commandIndex rows whose name matches the current
// input token by prefix — the same rule completeToken uses (commands.go).
// commandIndex is the single source of truth and already sorted, so the
// rows need no further ordering.
func (m model) paletteItems() []cmdEntry {
v := m.ta.Value()
out := make([]cmdEntry, 0, len(commandIndex))
for _, e := range commandIndex {
if strings.HasPrefix(e.name, v) {
out = append(out, e)
}
}
return out
}
// clampPaletteCursor keeps pal.cursor within [0, n-1]; an empty set parks it
// at 0. Callers clamp after a cursor move so the highlight never points past
// the visible rows.
func (m *model) clampPaletteCursor(n int) {
if n <= 0 || m.pal.cursor < 0 {
m.pal.cursor = 0
return
}
if m.pal.cursor > n-1 {
m.pal.cursor = n - 1
}
}
// acceptPalette commits the highlighted row: it fills the input with the
// command name plus a trailing space (which trips the open predicate and
// closes the palette) and moves the cursor to the end. It never submits —
// the subsequent Enter does. An empty filtered set is a no-op.
func (m model) acceptPalette(items []cmdEntry) model {
if len(items) == 0 {
return m
}
idx := m.pal.cursor
if idx < 0 || idx >= len(items) {
idx = 0
}
m.ta.SetValue(items[idx].name + " ")
m.ta.CursorEnd()
m.pal.cursor = 0
return m
}
// renderPalette formats the dropdown as a single multi-line string (no
// trailing newline; the caller frames it). The selected row carries a brand
// "› " marker and a brand-coloured name; the rest use the blue command-name
// style. Names pad to a column so purposes align. Only the purpose is
// width-clipped (it is the variable-length part), which keeps the per-segment
// styling intact under colour. An empty set renders a single dim "no match"
// row. No new colours: brand/key/dim are reused from style.go.
func renderPalette(items []cmdEntry, cursor, width int, st styles) string {
if len(items) == 0 {
return " " + st.dim.Render("no match")
}
nameCol := 0
for _, e := range items {
if n := len(e.name); n > nameCol {
nameCol = n
}
}
if cursor < 0 {
cursor = 0
}
if cursor > len(items)-1 {
cursor = len(items) - 1
}
// Scroll a fixed-height window so the highlighted row is always shown:
// without this the selected item could fall into the hidden overflow
// (e.g. cursor on the 9th of 9 commands with an 8-row cap).
start := 0
if cursor >= paletteMaxRows {
start = cursor - paletteMaxRows + 1
}
end := start + paletteMaxRows
if end > len(items) {
end = len(items)
}
shown := items[start:end]
rows := make([]string, 0, len(shown)+1)
for i, e := range shown {
marker := " "
nameStyle := st.key
if start+i == cursor {
marker = st.brand.Render("› ")
nameStyle = st.brand
}
pad := strings.Repeat(" ", nameCol-len(e.name))
purpose := e.purpose
if width > 0 {
// Columns before the purpose: 2 (marker) + nameCol + 2 (gap).
avail := width - 2 - nameCol - 2
if avail < 0 {
avail = 0
}
purpose = truncate(purpose, avail)
}
rows = append(rows, marker+nameStyle.Render(e.name)+pad+" "+st.dim.Render(purpose))
}
if hidden := len(items) - len(shown); hidden > 0 {
rows = append(rows, " "+st.dim.Render(fmt.Sprintf("+%d more", hidden)))
}
return strings.Join(rows, "\n")
}