mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
ce32cea7ee
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)
344 lines
10 KiB
Go
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
|
|
}
|