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") }