Files
kit/internal/ui/slash_command_input.go
T
Ed Zynda ce32cea7ee feat: upgrade charmbracelet libs to v2 (bubbletea, lipgloss, bubbles)
Migrate from github.com/charmbracelet/* v1 to charm.land/* v2 vanity imports.

Key changes:
- bubbletea: View() returns tea.View, KeyMsg -> KeyPressMsg, msg.String() matching
- lipgloss: AdaptiveColor replaced with cached dark-bg detection helper
- bubbles/textarea: Styles()/SetStyles() pattern, KeyMap.InsertNewline override
- bubbles/progress: SetWidth(), WithDefaultBlend(), typed Update return
- Input: enter always submits, ctrl+j/alt+enter insert newlines
- User message newlines preserved through glamour via \n -> \n\n conversion
- glamour stays at v1 (no v2 exists)
2026-02-25 17:07:09 +03:00

344 lines
10 KiB
Go

package ui
import (
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// SlashCommandInput provides an interactive text input field with intelligent
// slash command autocomplete functionality. It displays a popup menu of matching
// commands as the user types, supporting fuzzy matching and keyboard navigation.
type SlashCommandInput struct {
textarea textarea.Model
commands []SlashCommand
showPopup bool
filtered []FuzzyMatch
selected int
width int
lastValue string
popupHeight int
title string
quitting bool
value string
submitNext bool // Flag to submit on next update
renderedLines int // Track how many lines were rendered
}
// NewSlashCommandInput creates and initializes a new slash command input field with
// the specified width and title. The input supports multi-line text entry, command
// autocomplete, and is styled to match the application's theme.
func NewSlashCommandInput(width int, title string) *SlashCommandInput {
ta := textarea.New()
ta.Placeholder = "Type your message..."
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 5000
ta.SetWidth(width - 8) // Account for container padding, border and internal padding
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
// Style the textarea to match huh theme
styles := ta.Styles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
styles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
styles.Focused.Prompt = lipgloss.NewStyle()
styles.Focused.CursorLine = lipgloss.NewStyle()
ta.SetStyles(styles)
return &SlashCommandInput{
textarea: ta,
commands: SlashCommands,
width: width,
popupHeight: 7,
title: title,
}
}
// Init implements the tea.Model interface, returning the initial command to start
// the cursor blinking animation for the text input field.
func (s *SlashCommandInput) Init() tea.Cmd {
return textarea.Blink
}
// Update implements the tea.Model interface, handling keyboard input for text entry,
// command selection, and navigation. Manages the autocomplete popup display and
// processes submission or cancellation actions.
func (s *SlashCommandInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// Check if we need to submit after updating the view
if s.submitNext {
s.value = s.textarea.Value()
s.quitting = true
return s, tea.Quit
}
switch msg := msg.(type) {
case tea.KeyPressMsg: // Check for quit keys first (when popup is not shown)
if !s.showPopup {
switch msg.String() {
case "ctrl+c", "esc":
s.quitting = true
return s, tea.Quit
case "ctrl+d", "enter": // Enter always submits
s.value = s.textarea.Value()
s.quitting = true
return s, tea.Quit
}
}
// Handle popup navigation
if s.showPopup {
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up"))):
if s.selected > 0 {
s.selected--
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))):
if s.selected < len(s.filtered)-1 {
s.selected++
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if s.selected < len(s.filtered) {
// Complete with selected command
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
// Move cursor to end
s.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
// Populate the field with the selected command
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.textarea.CursorEnd()
// Hide the popup
s.showPopup = false
s.selected = 0
// Set flag to submit on next update (after view refresh)
s.submitNext = true
// Force a refresh
return s, nil
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
s.showPopup = false
s.selected = 0
return s, nil
}
}
// Update textarea
s.textarea, cmd = s.textarea.Update(msg)
// Check if we should show/update popup
value := s.textarea.Value()
if value != s.lastValue {
s.lastValue = value
// Only show popup if we're on the first line and it starts with /
lines := strings.Split(value, "\n")
if len(lines) > 0 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") && len(lines) == 1 {
// Show and update popup
s.showPopup = true
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
s.selected = 0
} else {
// Hide popup
s.showPopup = false
}
}
return s, cmd
default:
// Pass through other messages
s.textarea, cmd = s.textarea.Update(msg)
return s, cmd
}
}
// View implements the tea.Model interface, rendering the complete input field
// including the title, text area, autocomplete popup (when active), and help text.
// The view adapts based on whether single or multi-line input is detected.
func (s *SlashCommandInput) View() tea.View {
// Add left padding to entire component (2 spaces like other UI elements)
containerStyle := lipgloss.NewStyle().PaddingLeft(2)
// Title
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("252")).
MarginBottom(1)
// Input box with huh-like styling
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(lipgloss.Color("39")).
PaddingLeft(1).
Width(s.width - 2) // Account for container padding
// Build the view
var view strings.Builder
view.WriteString(titleStyle.Render(s.title))
view.WriteString("\n")
view.WriteString(inputBoxStyle.Render(s.textarea.View()))
// Count rendered lines
s.renderedLines = 2 + s.textarea.Height() // title + newline + textarea height
// Add popup if visible
if s.showPopup && len(s.filtered) > 0 {
view.WriteString("\n")
view.WriteString(s.renderPopup())
// Add popup lines
visibleItems := min(len(s.filtered), s.popupHeight)
scrollIndicators := 0
if s.selected >= s.popupHeight {
scrollIndicators++ // top indicator
}
if len(s.filtered) > s.popupHeight {
scrollIndicators++ // bottom indicator
}
popupLines := visibleItems + scrollIndicators + 5 // items + scroll + border + padding + footer
s.renderedLines += 1 + popupLines // newline + popup
}
// Add help text at bottom
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
MarginTop(1)
helpText := "enter submit • ctrl+j / alt+enter new line"
view.WriteString("\n")
view.WriteString(helpStyle.Render(helpText))
s.renderedLines += 2 // newline + help text
// Apply container padding to entire view
return tea.NewView(containerStyle.Render(view.String()))
}
// renderPopup renders the autocomplete popup
func (s *SlashCommandInput) renderPopup() string {
// Popup styling
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("236")).
Padding(1, 2).
Width(s.width - 4). // Account for container padding
MarginLeft(0) // No extra margin needed due to container padding
var items []string
// Calculate visible window
visibleItems := min(len(s.filtered), s.popupHeight)
startIdx := 0
// Adjust window to keep selected item visible
if s.selected >= s.popupHeight {
startIdx = s.selected - s.popupHeight + 1
}
endIdx := min(startIdx+visibleItems, len(s.filtered))
for i := startIdx; i < endIdx; i++ {
match := s.filtered[i]
cmd := match.Command
// Create the selection indicator
var indicator string
if i == s.selected {
indicator = lipgloss.NewStyle().
Foreground(lipgloss.Color("39")).
Render("> ")
} else {
indicator = " "
}
// Format item
nameStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("39")).
Bold(true)
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("243"))
// Highlight selected item
if i == s.selected {
nameStyle = nameStyle.Foreground(lipgloss.Color("87"))
descStyle = descStyle.Foreground(lipgloss.Color("250"))
}
// Format with proper spacing
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(cmd.Name)
// Truncate description if needed
desc := cmd.Description
maxDescLen := s.width - nameWidth - 14 // Account for padding and indicator
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
}
line := indicator + name + descStyle.Render(desc)
items = append(items, line)
}
// Add scroll indicators if needed
if startIdx > 0 {
scrollUpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
items = append([]string{scrollUpStyle.Render(" ↑ more above")}, items...)
}
if endIdx < len(s.filtered) {
scrollDownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
items = append(items, scrollDownStyle.Render(" ↓ more below"))
}
// Join items
content := strings.Join(items, "\n")
// Add footer hint
footerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("238")).
Italic(true)
footer := footerStyle.Render("↑↓ navigate • tab complete • ↵ select • esc dismiss")
// Combine content and footer
popupContent := content + "\n\n" + footer
return popupStyle.Render(popupContent)
}
// Value returns the final text value entered by the user after submission.
// This will be empty if the input was cancelled.
func (s *SlashCommandInput) Value() string {
return s.value
}
// Cancelled returns true if the user cancelled the input operation (e.g., by
// pressing ESC or Ctrl+C) without submitting any text.
func (s *SlashCommandInput) Cancelled() bool {
return s.quitting && s.value == ""
}
// RenderedLines returns the total number of terminal lines used by the last
// rendered view, including the title, input area, popup, and help text. This
// is used for proper screen clearing when the input is dismissed.
func (s *SlashCommandInput) RenderedLines() int {
return s.renderedLines
}