Merge pull request #294 from charmbracelet/charm-434

Kujtim Hoxha created

feat(tui): completions: dynamically adjust width based on items

Change summary

internal/tui/components/chat/editor/editor.go      | 57 ++++++--
internal/tui/components/completions/completions.go | 98 ++++++++++++---
internal/tui/page/chat/chat.go                     |  4 
internal/tui/tui.go                                | 13 -
4 files changed, 125 insertions(+), 47 deletions(-)

Detailed changes

internal/tui/components/chat/editor/editor.go 🔗

@@ -161,10 +161,17 @@ func (m *editorCmp) send() tea.Cmd {
 	)
 }
 
+func (m *editorCmp) repositionCompletions() tea.Msg {
+	x, y := m.completionsPosition()
+	return completions.RepositionCompletionsMsg{X: x, Y: y}
+}
+
 func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		return m, m.repositionCompletions
 	case filepicker.FilePickedMsg:
 		if len(m.attachments) >= maxAttachments {
 			return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
@@ -182,32 +189,37 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, nil
 		}
 		if item, ok := msg.Value.(FileCompletionItem); ok {
+			word := m.textarea.Word()
 			// If the selected item is a file, insert its path into the textarea
 			value := m.textarea.Value()
-			value = value[:m.completionsStartIndex]
-			value += item.Path
+			value = value[:m.completionsStartIndex] + // Remove the current query
+				item.Path + // Insert the file path
+				value[m.completionsStartIndex+len(word):] // Append the rest of the value
+			// XXX: This will always move the cursor to the end of the textarea.
 			m.textarea.SetValue(value)
+			m.textarea.MoveToEnd()
 			if !msg.Insert {
 				m.isCompletionsOpen = false
 				m.currentQuery = ""
 				m.completionsStartIndex = 0
 			}
-			return m, nil
 		}
 	case openEditorMsg:
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
 	case tea.KeyPressMsg:
+		cur := m.textarea.Cursor()
+		curIdx := m.textarea.Width()*cur.Y + cur.X
 		switch {
 		// Completions
 		case msg.String() == "/" && !m.isCompletionsOpen &&
-			// only show if beginning of prompt, or if previous char is a space:
-			(len(m.textarea.Value()) == 0 || m.textarea.Value()[len(m.textarea.Value())-1] == ' '):
+			// only show if beginning of prompt, or if previous char is a space or newline:
+			(len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
 			m.isCompletionsOpen = true
 			m.currentQuery = ""
-			m.completionsStartIndex = len(m.textarea.Value())
+			m.completionsStartIndex = curIdx
 			cmds = append(cmds, m.startCompletions)
-		case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
+		case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
 			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
 		}
 		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
@@ -244,6 +256,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if key.Matches(msg, m.keyMap.Newline) {
 			m.textarea.InsertRune('\n')
+			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
 		}
 		// Handle Enter key
 		if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
@@ -275,12 +288,18 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					// XXX: wont' work if editing in the middle of the field.
 					m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
 					m.currentQuery = word[1:]
+					x, y := m.completionsPosition()
+					x -= len(m.currentQuery)
 					m.isCompletionsOpen = true
-					cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
-						Query:  m.currentQuery,
-						Reopen: m.isCompletionsOpen,
-					}))
-				} else {
+					cmds = append(cmds,
+						util.CmdHandler(completions.FilterCompletionsMsg{
+							Query:  m.currentQuery,
+							Reopen: m.isCompletionsOpen,
+							X:      x,
+							Y:      y,
+						}),
+					)
+				} else if m.isCompletionsOpen {
 					m.isCompletionsOpen = false
 					m.currentQuery = ""
 					m.completionsStartIndex = 0
@@ -293,6 +312,16 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
+func (m *editorCmp) completionsPosition() (int, int) {
+	cur := m.textarea.Cursor()
+	if cur == nil {
+		return m.x, m.y + 1 // adjust for padding
+	}
+	x := cur.X + m.x
+	y := cur.Y + m.y + 1 // adjust for padding
+	return x, y
+}
+
 func (m *editorCmp) Cursor() *tea.Cursor {
 	cursor := m.textarea.Cursor()
 	if cursor != nil {
@@ -373,9 +402,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
 		})
 	}
 
-	cur := m.textarea.Cursor()
-	x := cur.X + m.x // adjust for padding
-	y := cur.Y + m.y + 1
+	x, y := m.completionsPosition()
 	return completions.OpenCompletionsMsg{
 		Completions: completionItems,
 		X:           x,

internal/tui/components/completions/completions.go 🔗

@@ -27,6 +27,12 @@ type OpenCompletionsMsg struct {
 type FilterCompletionsMsg struct {
 	Query  string // The query to filter completions
 	Reopen bool
+	X      int // X position for the completions popup
+	Y      int // Y position for the completions popup
+}
+
+type RepositionCompletionsMsg struct {
+	X, Y int
 }
 
 type CompletionsClosedMsg struct{}
@@ -51,18 +57,24 @@ type Completions interface {
 }
 
 type completionsCmp struct {
-	width  int
-	height int  // Height of the completions component`
-	x      int  // X position for the completions popup
-	y      int  // Y position for the completions popup
-	open   bool // Indicates if the completions are open
-	keyMap KeyMap
+	wWidth    int // The window width
+	wHeight   int // The window height
+	width     int
+	lastWidth int
+	height    int  // Height of the completions component`
+	x, xorig  int  // X position for the completions popup
+	y         int  // Y position for the completions popup
+	open      bool // Indicates if the completions are open
+	keyMap    KeyMap
 
 	list  list.ListModel
 	query string // The current filter query
 }
 
-const maxCompletionsWidth = 80 // Maximum width for the completions popup
+const (
+	maxCompletionsWidth = 80 // Maximum width for the completions popup
+	minCompletionsWidth = 20 // Minimum width for the completions popup
+)
 
 func New() Completions {
 	completionsKeyMap := DefaultKeyMap()
@@ -83,7 +95,7 @@ func New() Completions {
 	)
 	return &completionsCmp{
 		width:  0,
-		height: 0,
+		height: maxCompletionsHeight,
 		list:   l,
 		query:  "",
 		keyMap: completionsKeyMap,
@@ -102,8 +114,7 @@ func (c *completionsCmp) Init() tea.Cmd {
 func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		c.width = min(msg.Width-c.x, maxCompletionsWidth)
-		c.height = min(msg.Height-c.y, 15)
+		c.wWidth, c.wHeight = msg.Width, msg.Height
 		return c, nil
 	case tea.KeyPressMsg:
 		switch {
@@ -154,13 +165,16 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case key.Matches(msg, c.keyMap.Cancel):
 			return c, util.CmdHandler(CloseCompletionsMsg{})
 		}
+	case RepositionCompletionsMsg:
+		c.x, c.y = msg.X, msg.Y
+		c.adjustPosition()
 	case CloseCompletionsMsg:
 		c.open = false
 		return c, util.CmdHandler(CompletionsClosedMsg{})
 	case OpenCompletionsMsg:
 		c.open = true
 		c.query = ""
-		c.x = msg.X
+		c.x, c.xorig = msg.X, msg.X
 		c.y = msg.Y
 		items := []util.Model{}
 		t := styles.CurrentTheme()
@@ -168,10 +182,18 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
 			items = append(items, item)
 		}
-		c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
+		width := listWidth(items)
+		if len(items) == 0 {
+			width = listWidth(c.list.Items())
+		}
+		if c.x+width >= c.wWidth {
+			c.x = c.wWidth - width - 1
+		}
+		c.width = width
+		c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
 		return c, tea.Batch(
-			c.list.SetSize(c.width, c.height),
 			c.list.SetItems(items),
+			c.list.SetSize(c.width, c.height),
 			util.CmdHandler(CompletionsOpenedMsg{}),
 		)
 	case FilterCompletionsMsg:
@@ -195,8 +217,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		c.query = msg.Query
 		var cmds []tea.Cmd
 		cmds = append(cmds, c.list.Filter(msg.Query))
-		itemsLen := len(c.list.Items())
-		c.height = max(min(maxCompletionsHeight, itemsLen), 1)
+		items := c.list.Items()
+		itemsLen := len(items)
+		c.xorig = msg.X
+		c.x, c.y = msg.X, msg.Y
+		c.adjustPosition()
 		cmds = append(cmds, c.list.SetSize(c.width, c.height))
 		if itemsLen == 0 {
 			cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
@@ -209,21 +234,54 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return c, nil
 }
 
+func (c *completionsCmp) adjustPosition() {
+	items := c.list.Items()
+	itemsLen := len(items)
+	width := listWidth(items)
+	c.lastWidth = c.width
+	if c.x < 0 || width < c.lastWidth {
+		c.x = c.xorig
+	} else if c.x+width >= c.wWidth {
+		c.x = c.wWidth - width - 1
+	}
+	c.width = width
+	c.height = max(min(maxCompletionsHeight, itemsLen), 1)
+}
+
 // View implements Completions.
 func (c *completionsCmp) View() string {
 	if !c.open || len(c.list.Items()) == 0 {
 		return ""
 	}
 
-	return c.style().Render(c.list.View())
-}
-
-func (c *completionsCmp) style() lipgloss.Style {
 	t := styles.CurrentTheme()
-	return t.S().Base.
+	style := t.S().Base.
 		Width(c.width).
 		Height(c.height).
 		Background(t.BgSubtle)
+
+	return style.Render(c.list.View())
+}
+
+// listWidth returns the width of the last 10 items in the list, which is used
+// to determine the width of the completions popup.
+// Note this only works for [completionItemCmp] items.
+func listWidth[T any](items []T) int {
+	var width int
+	if len(items) == 0 {
+		return width
+	}
+
+	for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
+		item, ok := any(items[i]).(*completionItemCmp)
+		if !ok {
+			continue
+		}
+		itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
+		width = max(width, itemWidth)
+	}
+
+	return width
 }
 
 func (c *completionsCmp) Open() bool {

internal/tui/page/chat/chat.go 🔗

@@ -165,7 +165,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		p.keyboardEnhancements = msg
 		return p, nil
 	case tea.WindowSizeMsg:
-		return p, p.SetSize(msg.Width, msg.Height)
+		u, cmd := p.editor.Update(msg)
+		p.editor = u.(editor.Editor)
+		return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
 	case CancelTimerExpiredMsg:
 		p.isCanceling = false
 		return p, nil

internal/tui/tui.go 🔗

@@ -118,19 +118,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, a.handleWindowResize(msg.Width, msg.Height)
 
 	// Completions messages
-	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
+	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
+		completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
 		u, completionCmd := a.completions.Update(msg)
 		a.completions = u.(completions.Completions)
-		switch msg := msg.(type) {
-		case completions.OpenCompletionsMsg:
-			x, _ := a.completions.Position()
-			if a.completions.Width()+x >= a.wWidth {
-				// Adjust X position to fit in the window.
-				msg.X = a.wWidth - a.completions.Width() - 1
-				u, completionCmd = a.completions.Update(msg)
-				a.completions = u.(completions.Completions)
-			}
-		}
 		return a, completionCmd
 
 	// Dialog messages