.gitignore 🔗
@@ -48,6 +48,5 @@ Thumbs.db
/tmp/
manpages/
-completions/
-!internal/tui/components/completions/
+completions/crush.*sh
.prettierignore
Carlos Alexandro Becker created
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
.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(-)
@@ -48,6 +48,5 @@ Thumbs.db
/tmp/
manpages/
-completions/
-!internal/tui/components/completions/
+completions/crush.*sh
.prettierignore
@@ -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()
+}
@@ -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)
+)
@@ -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,
+ }
+}
@@ -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
@@ -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
@@ -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...",
@@ -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
}