fix(tui): completions: improve positioning and handling completions

Ayman Bagabas created

With this, the completions popup will now reposition itself on
fitlering, resizing, and when the cursor moves. It also ensures that the
completions are correctly positioned relative to the textarea cursor
position.

Change summary

internal/tui/components/chat/editor/editor.go      | 54 ++++++++++-----
internal/tui/components/completions/completions.go | 29 +++++--
internal/tui/tui.go                                |  3 
3 files changed, 58 insertions(+), 28 deletions(-)

Detailed changes

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

@@ -162,9 +162,7 @@ func (m *editorCmp) send() tea.Cmd {
 }
 
 func (m *editorCmp) repositionCompletions() 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.RepositionCompletionsMsg{X: x, Y: y}
 }
 
@@ -191,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) {
@@ -253,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) {
@@ -284,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
@@ -302,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 {
@@ -382,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,8 @@ 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 {
@@ -165,6 +167,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case RepositionCompletionsMsg:
 		c.x, c.y = msg.X, msg.Y
+		c.adjustPosition()
 	case CloseCompletionsMsg:
 		c.open = false
 		return c, util.CmdHandler(CompletionsClosedMsg{})
@@ -216,15 +219,9 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, c.list.Filter(msg.Query))
 		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)
+		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{}))
@@ -237,6 +234,20 @@ 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 {

internal/tui/tui.go 🔗

@@ -116,7 +116,8 @@ 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, completions.RepositionCompletionsMsg:
+	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
+		completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
 		u, completionCmd := a.completions.Update(msg)
 		a.completions = u.(completions.Completions)
 		return a, completionCmd