package tui import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // menuMaxRows caps the completion list. Past about eight rows the menu is // eating the transcript, and a list that long is faster to filter than to // scroll anyway. const menuMaxRows = 8 // menu is the slash-command completion list under the prompt. It is closed // when it holds no items; there is no separate flag to disagree with that. type menu struct { items []command idx int // dismissed is the prompt text the menu was last closed on. Accepting a // completion leaves "/clear" in the box, which is exactly the text that // would re-open the menu, so closing remembers what it closed on and // stays shut until something else is typed. dismissed string } func (m *model) menuOpen() bool { return len(m.menu.items) > 0 } // menuQuery reports what has been typed after the slash, and whether a // command is being typed at all. A slash anywhere but the start is prose; a // space means the name is settled and what follows is its arguments. func menuQuery(value string) (string, bool) { if !strings.HasPrefix(value, "/") { return "", false } rest := value[1:] if strings.ContainsAny(rest, " \t\n") { return "", false } return strings.ToLower(rest), true } // refreshMenu recomputes the list from what is in the prompt box. It runs // after every keystroke the textarea sees, so the list follows the text // rather than being opened and closed by particular keys. func (m *model) refreshMenu() { value := m.input.Value() typed, typing := menuQuery(value) if !typing || value == m.menu.dismissed || m.phase != phaseIdle { m.menu.items, m.menu.idx = nil, 0 return } // Keep the highlight on the command it was on while the list narrows // around it; otherwise start at the top. var was string if m.menuOpen() && m.menu.idx < len(m.menu.items) { was = m.menu.items[m.menu.idx].name } m.menu.items, m.menu.idx = matchCommands(m.commands(), typed), 0 for i, c := range m.menu.items { if c.name == was { m.menu.idx = i } } } func (m *model) closeMenu() { m.menu.items, m.menu.idx = nil, 0 m.menu.dismissed = m.input.Value() } // onMenuKey handles the keys that belong to an open menu, and reports whether // it took the key. Everything it does not take falls through to the prompt, // which is what keeps typing a filter rather than a command of its own. func (m *model) onMenuKey(msg tea.KeyMsg) (tea.Cmd, bool) { n := len(m.menu.items) switch msg.String() { case "up", "ctrl+p": m.menu.idx = (m.menu.idx - 1 + n) % n return nil, true case "down", "ctrl+n": m.menu.idx = (m.menu.idx + 1) % n return nil, true case "tab", "enter": m.acceptCompletion() return nil, true case "esc": m.closeMenu() return nil, true } return nil, false } // acceptCompletion puts the highlighted command in the prompt box. It does // not send it: a command that takes arguments is half-typed at this point, and // one that does not is one more enter away. func (m *model) acceptCompletion() { c := m.menu.items[m.menu.idx] text := "/" + c.name if c.args != "" { text += " " } m.input.SetValue(text) m.input.CursorEnd() m.closeMenu() m.growInput() } // menuRows is how many lines the menu may take: what is left after the rest of // the bottom chrome and one row of transcript, capped. func (m *model) menuRows() int { // activity line, the prompt box's three, the status line, the mode line, // and a row of transcript to keep the menu from swallowing the screen. room := m.height - 6 - m.input.Height() return maxInt(1, minInt(menuMaxRows, room)) } // menuView renders the list under the prompt: the highlighted row in coral // with its description beside it, the rest dimmed. func (m *model) menuView() string { if !m.menuOpen() { return "" } rows := m.menuRows() shown := rows if len(m.menu.items) > rows { // The last row goes to the count of what did not fit. shown = maxInt(1, rows-1) } start := 0 if m.menu.idx >= shown { start = m.menu.idx - shown + 1 } end := minInt(len(m.menu.items), start+shown) // One column for the names, so the descriptions line up. A long skill // name is left to overflow its column rather than being truncated: the // name is the thing you are picking, and half of it is no use. width := 0 for _, c := range m.menu.items[start:end] { width = maxInt(width, lipgloss.Width("/"+c.name)) } width = minInt(width, maxInt(12, m.width/3)) var b strings.Builder for i := start; i < end; i++ { c := m.menu.items[i] name := pad("/"+c.name, width) desc := c.desc if c.tag != "" { desc += " (" + c.tag + ")" } var line string if i == m.menu.idx { line = styKey.Render("❯ "+name) + " " + styText.Render(desc) } else { line = " " + styDim.Render(name) + " " + styFaint.Render(desc) } b.WriteString(truncateStyled(" "+line, maxInt(1, m.width)) + "\n") } if n := len(m.menu.items) - (end - start); n > 0 { b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), maxInt(1, m.width)) + "\n") } return strings.TrimRight(b.String(), "\n") } // pad widens s to width cells, leaving anything already wider alone. func pad(s string, width int) string { if n := width - lipgloss.Width(s); n > 0 { return s + strings.Repeat(" ", n) } return s }