From 23e0b06d0054203cf4deb033b18e48037ed99e5d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 8 Jan 2026 12:03:03 -0300 Subject: [PATCH] feat: completions menu (#1781) Signed-off-by: Carlos Alexandro Becker --- .gitignore | 3 +- internal/ui/completions/completions.go | 271 +++++++++++++++++++++++++ internal/ui/completions/item.go | 185 +++++++++++++++++ internal/ui/completions/keys.go | 74 +++++++ internal/ui/list/filterable.go | 2 + internal/ui/list/list.go | 95 +++++++-- internal/ui/model/ui.go | 191 ++++++++++++++++- internal/ui/styles/styles.go | 12 ++ 8 files changed, 809 insertions(+), 24 deletions(-) create mode 100644 internal/ui/completions/completions.go create mode 100644 internal/ui/completions/item.go create mode 100644 internal/ui/completions/keys.go diff --git a/.gitignore b/.gitignore index 01510713f6c6886781775f35d27d95fa96d3ef2f..008dcff3153d850de53e4e792fb320355f0009ea 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,5 @@ Thumbs.db /tmp/ manpages/ -completions/ -!internal/tui/components/completions/ +completions/crush.*sh .prettierignore diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go new file mode 100644 index 0000000000000000000000000000000000000000..4e5f9f64d46512c29b69ea004a772d3ce89c3777 --- /dev/null +++ b/internal/ui/completions/completions.go @@ -0,0 +1,271 @@ +package completions + +import ( + "slices" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" +) + +const ( + minHeight = 1 + maxHeight = 10 + minWidth = 10 + maxWidth = 100 +) + +// SelectionMsg is sent when a completion is selected. +type SelectionMsg struct { + Value any + Insert bool // If true, insert without closing. +} + +// ClosedMsg is sent when the completions are closed. +type ClosedMsg struct{} + +// FilesLoadedMsg is sent when files have been loaded for completions. +type FilesLoadedMsg struct { + Files []string +} + +// Completions represents the completions popup component. +type Completions struct { + // Popup dimensions + width int + height int + + // State + open bool + query string + + // Key bindings + keyMap KeyMap + + // List component + list *list.FilterableList + + // Styling + normalStyle lipgloss.Style + focusedStyle lipgloss.Style + matchStyle lipgloss.Style +} + +// New creates a new completions component. +func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions { + l := list.NewFilterableList() + l.SetGap(0) + l.SetReverse(true) + + return &Completions{ + keyMap: DefaultKeyMap(), + list: l, + normalStyle: normalStyle, + focusedStyle: focusedStyle, + matchStyle: matchStyle, + } +} + +// IsOpen returns whether the completions popup is open. +func (c *Completions) IsOpen() bool { + return c.open +} + +// Query returns the current filter query. +func (c *Completions) Query() string { + return c.query +} + +// Size returns the visible size of the popup. +func (c *Completions) Size() (width, height int) { + visible := len(c.list.VisibleItems()) + return c.width, min(visible, c.height) +} + +// KeyMap returns the key bindings. +func (c *Completions) KeyMap() KeyMap { + return c.keyMap +} + +// OpenWithFiles opens the completions with file items from the filesystem. +func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd { + return func() tea.Msg { + files, _, _ := fsext.ListDirectory(".", nil, depth, limit) + slices.Sort(files) + return FilesLoadedMsg{Files: files} + } +} + +// SetFiles sets the file items on the completions popup. +func (c *Completions) SetFiles(files []string) { + items := make([]list.FilterableItem, 0, len(files)) + width := 0 + for _, file := range files { + file = strings.TrimPrefix(file, "./") + item := NewCompletionItem( + file, + FileCompletionValue{Path: file}, + c.normalStyle, + c.focusedStyle, + c.matchStyle, + ) + + width = max(width, ansi.StringWidth(file)) + items = append(items, item) + } + + c.open = true + c.query = "" + c.list.SetItems(items...) + c.list.SetFilter("") // Clear any previous filter. + c.list.Focus() + + c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) + c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) + c.list.SetSize(c.width, c.height) + c.list.SelectFirst() + c.list.ScrollToSelected() +} + +// Close closes the completions popup. +func (c *Completions) Close() tea.Cmd { + c.open = false + return func() tea.Msg { + return ClosedMsg{} + } +} + +// Filter filters the completions with the given query. +func (c *Completions) Filter(query string) { + if !c.open { + return + } + + if query == c.query { + return + } + + c.query = query + c.list.SetFilter(query) + + items := c.list.VisibleItems() + width := 0 + for _, item := range items { + width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text())) + } + c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) + c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) + c.list.SetSize(c.width, c.height) + c.list.SelectFirst() + c.list.ScrollToSelected() +} + +// HasItems returns whether there are visible items. +func (c *Completions) HasItems() bool { + return len(c.list.VisibleItems()) > 0 +} + +// Update handles key events for the completions. +func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Cmd, bool) { + if !c.open { + return nil, false + } + + switch { + case key.Matches(msg, c.keyMap.Up): + c.selectPrev() + return nil, true + + case key.Matches(msg, c.keyMap.Down): + c.selectNext() + return nil, true + + case key.Matches(msg, c.keyMap.UpInsert): + c.selectPrev() + return c.selectCurrent(true), true + + case key.Matches(msg, c.keyMap.DownInsert): + c.selectNext() + return c.selectCurrent(true), true + + case key.Matches(msg, c.keyMap.Select): + return c.selectCurrent(false), true + + case key.Matches(msg, c.keyMap.Cancel): + return c.Close(), true + } + + return nil, false +} + +// selectPrev selects the previous item with circular navigation. +func (c *Completions) selectPrev() { + items := c.list.VisibleItems() + if len(items) == 0 { + return + } + if !c.list.SelectPrev() { + c.list.WrapToEnd() + } + c.list.ScrollToSelected() +} + +// selectNext selects the next item with circular navigation. +func (c *Completions) selectNext() { + items := c.list.VisibleItems() + if len(items) == 0 { + return + } + if !c.list.SelectNext() { + c.list.WrapToStart() + } + c.list.ScrollToSelected() +} + +// selectCurrent returns a command with the currently selected item. +func (c *Completions) selectCurrent(insert bool) tea.Cmd { + items := c.list.VisibleItems() + if len(items) == 0 { + return nil + } + + selected := c.list.Selected() + if selected < 0 || selected >= len(items) { + return nil + } + + item, ok := items[selected].(*CompletionItem) + if !ok { + return nil + } + + if !insert { + c.open = false + } + + return func() tea.Msg { + return SelectionMsg{ + Value: item.Value(), + Insert: insert, + } + } +} + +// Render renders the completions popup. +func (c *Completions) Render() string { + if !c.open { + return "" + } + + items := c.list.VisibleItems() + if len(items) == 0 { + return "" + } + + return c.list.Render() +} diff --git a/internal/ui/completions/item.go b/internal/ui/completions/item.go new file mode 100644 index 0000000000000000000000000000000000000000..1114083fd1a118649921ead3ea2288d6e6085632 --- /dev/null +++ b/internal/ui/completions/item.go @@ -0,0 +1,185 @@ +package completions + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" + "github.com/sahilm/fuzzy" +) + +// FileCompletionValue represents a file path completion value. +type FileCompletionValue struct { + Path string +} + +// CompletionItem represents an item in the completions list. +type CompletionItem struct { + text string + value any + match fuzzy.Match + focused bool + cache map[int]string + + // Styles + normalStyle lipgloss.Style + focusedStyle lipgloss.Style + matchStyle lipgloss.Style +} + +// NewCompletionItem creates a new completion item. +func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem { + return &CompletionItem{ + text: text, + value: value, + normalStyle: normalStyle, + focusedStyle: focusedStyle, + matchStyle: matchStyle, + } +} + +// Text returns the display text of the item. +func (c *CompletionItem) Text() string { + return c.text +} + +// Value returns the value of the item. +func (c *CompletionItem) Value() any { + return c.value +} + +// Filter implements [list.FilterableItem]. +func (c *CompletionItem) Filter() string { + return c.text +} + +// SetMatch implements [list.MatchSettable]. +func (c *CompletionItem) SetMatch(m fuzzy.Match) { + c.cache = nil + c.match = m +} + +// SetFocused implements [list.Focusable]. +func (c *CompletionItem) SetFocused(focused bool) { + if c.focused != focused { + c.cache = nil + } + c.focused = focused +} + +// Render implements [list.Item]. +func (c *CompletionItem) Render(width int) string { + return renderItem( + c.normalStyle, + c.focusedStyle, + c.matchStyle, + c.text, + c.focused, + width, + c.cache, + &c.match, + ) +} + +func renderItem( + normalStyle, focusedStyle, matchStyle lipgloss.Style, + text string, + focused bool, + width int, + cache map[int]string, + match *fuzzy.Match, +) string { + if cache == nil { + cache = make(map[int]string) + } + + cached, ok := cache[width] + if ok { + return cached + } + + innerWidth := width - 2 // Account for padding + // Truncate if needed. + if ansi.StringWidth(text) > innerWidth { + text = ansi.Truncate(text, innerWidth, "…") + } + + // Select base style. + style := normalStyle + matchStyle = matchStyle.Background(style.GetBackground()) + if focused { + style = focusedStyle + matchStyle = matchStyle.Background(style.GetBackground()) + } + + // Render full-width text with background. + content := style.Padding(0, 1).Width(width).Render(text) + + // Apply match highlighting using StyleRanges. + if len(match.MatchedIndexes) > 0 { + var ranges []lipgloss.Range + for _, rng := range matchedRanges(match.MatchedIndexes) { + start, stop := bytePosToVisibleCharPos(text, rng) + // Offset by 1 for the padding space. + ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle)) + } + content = lipgloss.StyleRanges(content, ranges...) + } + + cache[width] = content + return content +} + +// matchedRanges converts a list of match indexes into contiguous ranges. +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +// bytePosToVisibleCharPos converts byte positions to visible character positions. +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} + +// Ensure CompletionItem implements the required interfaces. +var ( + _ list.Item = (*CompletionItem)(nil) + _ list.FilterableItem = (*CompletionItem)(nil) + _ list.MatchSettable = (*CompletionItem)(nil) + _ list.Focusable = (*CompletionItem)(nil) +) diff --git a/internal/ui/completions/keys.go b/internal/ui/completions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..d150f1a96b05018bfeaf6fea0b45d2c5ea65ac06 --- /dev/null +++ b/internal/ui/completions/keys.go @@ -0,0 +1,74 @@ +package completions + +import ( + "charm.land/bubbles/v2/key" +) + +// KeyMap defines the key bindings for the completions component. +type KeyMap struct { + Down, + Up, + Select, + Cancel key.Binding + DownInsert, + UpInsert key.Binding +} + +// DefaultKeyMap returns the default key bindings for completions. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("down", "move down"), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("up", "move up"), + ), + Select: key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "select"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ), + DownInsert: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "insert next"), + ), + UpInsert: key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "insert previous"), + ), + } +} + +// KeyBindings returns all key bindings as a slice. +func (k KeyMap) KeyBindings() []key.Binding { + return []key.Binding{ + k.Down, + k.Up, + k.Select, + k.Cancel, + } +} + +// FullHelp returns the full help for the key bindings. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := k.KeyBindings() + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp returns the short help for the key bindings. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Up, + k.Down, + } +} diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go index de78041e3c2666830b6f5ce695472d46448abf0f..d3c227f0234028aea22fcc397d861c263cab034a 100644 --- a/internal/ui/list/filterable.go +++ b/internal/ui/list/filterable.go @@ -68,6 +68,8 @@ func (f *FilterableList) PrependItems(items ...FilterableItem) { // SetFilter sets the filter query and updates the list items. func (f *FilterableList) SetFilter(q string) { f.query = q + f.List.SetItems(f.VisibleItems()...) + f.ScrollToTop() } // FilterableItemsSource is a type that implements [fuzzy.Source] for filtering diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index cd2c36c0c8b99d8d9a4dca7b7e1a69a0d17705ed..8806551c537ecbfdcba8169bc05d7de79183b0ba 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -17,6 +17,9 @@ type List struct { // Gap between items (0 or less means no gap) gap int + // show list in reverse order + reverse bool + // Focus and selection state focused bool selectedIdx int // The current selected index -1 means no selection @@ -63,6 +66,11 @@ func (l *List) SetGap(gap int) { l.gap = gap } +// SetReverse shows the list in reverse order. +func (l *List) SetReverse(reverse bool) { + l.reverse = reverse +} + // Width returns the width of the list viewport. func (l *List) Width() int { return l.width @@ -126,6 +134,10 @@ func (l *List) ScrollBy(lines int) { return } + if l.reverse { + lines = -lines + } + if lines > 0 { // Scroll down // Calculate from the bottom how many lines needed to anchor the last @@ -269,6 +281,13 @@ func (l *List) Render() string { lines = lines[:l.height] } + if l.reverse { + // Reverse the lines so the list renders bottom-to-top. + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] + } + } + return strings.Join(lines, "\n") } @@ -440,12 +459,21 @@ func (l *List) IsSelectedLast() bool { return l.selectedIdx == len(l.items)-1 } -// SelectPrev selects the previous item in the list. +// SelectPrev selects the visually previous item (moves toward visual top). // It returns whether the selection changed. func (l *List) SelectPrev() bool { - if l.selectedIdx > 0 { - l.selectedIdx-- - return true + if l.reverse { + // In reverse, visual up = higher index + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + return true + } + } else { + // Normal: visual up = lower index + if l.selectedIdx > 0 { + l.selectedIdx-- + return true + } } return false } @@ -453,9 +481,18 @@ func (l *List) SelectPrev() bool { // SelectNext selects the next item in the list. // It returns whether the selection changed. func (l *List) SelectNext() bool { - if l.selectedIdx < len(l.items)-1 { - l.selectedIdx++ - return true + if l.reverse { + // In reverse, visual down = lower index + if l.selectedIdx > 0 { + l.selectedIdx-- + return true + } + } else { + // Normal: visual down = higher index + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + return true + } } return false } @@ -463,21 +500,49 @@ func (l *List) SelectNext() bool { // SelectFirst selects the first item in the list. // It returns whether the selection changed. func (l *List) SelectFirst() bool { - if len(l.items) > 0 { - l.selectedIdx = 0 - return true + if len(l.items) == 0 { + return false } - return false + l.selectedIdx = 0 + return true } -// SelectLast selects the last item in the list. +// SelectLast selects the last item in the list (highest index). // It returns whether the selection changed. func (l *List) SelectLast() bool { - if len(l.items) > 0 { + if len(l.items) == 0 { + return false + } + l.selectedIdx = len(l.items) - 1 + return true +} + +// WrapToStart wraps selection to the visual start (for circular navigation). +// In normal mode, this is index 0. In reverse mode, this is the highest index. +func (l *List) WrapToStart() bool { + if len(l.items) == 0 { + return false + } + if l.reverse { l.selectedIdx = len(l.items) - 1 - return true + } else { + l.selectedIdx = 0 } - return false + return true +} + +// WrapToEnd wraps selection to the visual end (for circular navigation). +// In normal mode, this is the highest index. In reverse mode, this is index 0. +func (l *List) WrapToEnd() bool { + if len(l.items) == 0 { + return false + } + if l.reverse { + l.selectedIdx = 0 + } else { + l.selectedIdx = len(l.items) - 1 + } + return true } // SelectedItem returns the currently selected item. It may be nil if no item diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6d093670b5296110e66667d1c27eb00759d7fa28..54f453c3ec90b05721bb4c2ab8ab5442d8da6655 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -30,6 +30,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" @@ -103,6 +104,13 @@ type UI struct { readyPlaceholder string workingPlaceholder string + // Completions state + completions *completions.Completions + completionsOpen bool + completionsStartIndex int + completionsQuery string + completionsPositionStart image.Point // x,y where user typed '@' + // Chat components chat *Chat @@ -133,14 +141,22 @@ func New(com *common.Common) *UI { ch := NewChat(com) + // Completions component + comp := completions.New( + com.Styles.Completions.Normal, + com.Styles.Completions.Focused, + com.Styles.Completions.Match, + ) + ui := &UI{ - com: com, - dialog: dialog.NewOverlay(), - keyMap: DefaultKeyMap(), - focus: uiFocusNone, - state: uiConfigure, - textarea: ta, - chat: ch, + com: com, + dialog: dialog.NewOverlay(), + keyMap: DefaultKeyMap(), + focus: uiFocusNone, + state: uiConfigure, + textarea: ta, + chat: ch, + completions: comp, } status := NewStatus(com, ui) @@ -335,6 +351,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } } + case completions.SelectionMsg: + // Handle file completion selection. + if item, ok := msg.Value.(completions.FileCompletionValue); ok { + m.insertFileCompletion(item.Path) + } + if !msg.Insert { + m.closeCompletions() + } + case completions.FilesLoadedMsg: + // Handle async file loading for completions. + if m.completionsOpen { + m.completions.SetFiles(msg.Files) + } + case completions.ClosedMsg: + m.completionsOpen = false case tea.KeyPressMsg: if cmd := m.handleKeyPressMsg(msg); cmd != nil { cmds = append(cmds, cmd) @@ -775,6 +806,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case uiChat, uiLanding, uiChatCompact: switch m.focus { case uiFocusEditor: + // Handle completions if open. + if m.completionsOpen { + if cmd, ok := m.completions.Update(msg); ok { + cmds = append(cmds, cmd) + return tea.Batch(cmds...) + } + } + switch { case key.Matches(msg, m.keyMap.Editor.SendMessage): value := m.textarea.Value() @@ -823,15 +862,57 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, m.openEditor(m.textarea.Value())) case key.Matches(msg, m.keyMap.Editor.Newline): m.textarea.InsertRune('\n') + m.closeCompletions() default: if handleGlobalKeys(msg) { // Handle global keys first before passing to textarea. break } + // Check for @ trigger before passing to textarea. + curValue := m.textarea.Value() + curIdx := len(curValue) + + // Trigger completions on @. + if msg.String() == "@" && !m.completionsOpen { + // Only show if beginning of prompt or after whitespace. + if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) { + m.completionsOpen = true + m.completionsQuery = "" + m.completionsStartIndex = curIdx + m.completionsPositionStart = m.completionsPosition() + depth, limit := m.com.Config().Options.TUI.Completions.Limits() + cmds = append(cmds, m.completions.OpenWithFiles(depth, limit)) + } + } + ta, cmd := m.textarea.Update(msg) m.textarea = ta cmds = append(cmds, cmd) + + // After updating textarea, check if we need to filter completions. + // Skip filtering on the initial @ keystroke since items are loading async. + if m.completionsOpen && msg.String() != "@" { + newValue := m.textarea.Value() + newIdx := len(newValue) + + // Close completions if cursor moved before start. + if newIdx <= m.completionsStartIndex { + m.closeCompletions() + } else if msg.String() == "space" { + // Close on space. + m.closeCompletions() + } else { + // Extract current word and filter. + word := m.textareaWord() + if strings.HasPrefix(word, "@") { + m.completionsQuery = word[1:] + m.completions.Filter(m.completionsQuery) + } else if m.completionsOpen { + m.closeCompletions() + } + } + } } case uiFocusMain: switch { @@ -982,6 +1063,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { // Add status and help layer m.status.Draw(scr, layout.status) + // Draw completions popup if open + if m.completionsOpen && m.completions.HasItems() { + w, h := m.completions.Size() + x := m.completionsPositionStart.X + y := m.completionsPositionStart.Y - h + + screenW := area.Dx() + if x+w > screenW { + x = screenW - w + } + x = max(0, x) + y = max(0, y) + + completionsView := uv.NewStyledString(m.completions.Render()) + completionsView.Draw(scr, image.Rectangle{ + Min: image.Pt(x, y), + Max: image.Pt(x+w, y+h), + }) + } + // Debugging rendering (visually see when the tui rerenders) if os.Getenv("CRUSH_UI_DEBUG") == "true" { debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) @@ -1489,6 +1590,82 @@ func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { return t.EditorPromptYoloDotsBlurred.Render() } +// closeCompletions closes the completions popup and resets state. +func (m *UI) closeCompletions() { + m.completionsOpen = false + m.completionsQuery = "" + m.completionsStartIndex = 0 + m.completions.Close() +} + +// insertFileCompletion inserts the selected file path into the textarea, +// replacing the @query, and adds the file as an attachment. +func (m *UI) insertFileCompletion(path string) { + value := m.textarea.Value() + word := m.textareaWord() + + // Find the @ and query to replace. + if m.completionsStartIndex > len(value) { + return + } + + // Build the new value: everything before @, the path, everything after query. + endIdx := m.completionsStartIndex + len(word) + if endIdx > len(value) { + endIdx = len(value) + } + + newValue := value[:m.completionsStartIndex] + path + value[endIdx:] + m.textarea.SetValue(newValue) + // XXX: This will always move the cursor to the end of the textarea. + m.textarea.MoveToEnd() + + // Add file as attachment. + content, err := os.ReadFile(path) + if err != nil { + // If it fails, let the LLM handle it later. + return + } + + m.attachments = append(m.attachments, message.Attachment{ + FilePath: path, + FileName: filepath.Base(path), + MimeType: mimeOf(content), + Content: content, + }) +} + +// completionsPosition returns the X and Y position for the completions popup. +func (m *UI) completionsPosition() image.Point { + cur := m.textarea.Cursor() + if cur == nil { + return image.Point{ + X: m.layout.editor.Min.X, + Y: m.layout.editor.Min.Y, + } + } + return image.Point{ + X: cur.X + m.layout.editor.Min.X, + Y: m.layout.editor.Min.Y + cur.Y, + } +} + +// textareaWord returns the current word at the cursor position. +func (m *UI) textareaWord() string { + return m.textarea.Word() +} + +// isWhitespace returns true if the byte is a whitespace character. +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// mimeOf detects the MIME type of the given content. +func mimeOf(content []byte) string { + mimeBufferSize := min(512, len(content)) + return http.DetectContentType(content[:mimeBufferSize]) +} + var readyPlaceholders = [...]string{ "Ready!", "Ready...", diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 55248f703400083e4eae36ca9405296111c6db29..0f0de03cdba36056e82910a5423aac56a80fbb04 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -330,6 +330,13 @@ type Styles struct { UpdateMessage lipgloss.Style SuccessMessage lipgloss.Style } + + // Completions popup styles + Completions struct { + Normal lipgloss.Style + Focused lipgloss.Style + Match lipgloss.Style + } } // ChromaTheme converts the current markdown chroma styles to a chroma @@ -1160,6 +1167,11 @@ func DefaultStyles() Styles { s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning) s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark) + // Completions styles + s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase) + s.Completions.Focused = base.Background(primary).Foreground(white) + s.Completions.Match = base.Underline(true) + return s }