on main help sign in
fix(tui): completions: improve positioning and handling completions

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.

commit97290c82f2e9d72808fe1634226da51b5a06b216 authorAyman Bagabas <ayman.bagabas@gmail.com> dateThu 24 Jul 2025, 13:59 (1 year ago) parentb446838
3 files changed+58−28
internal/tui/components/chat/editor/editor.go+36−18 internal/tui/components/completions/completions.go+20−9 internal/tui/tui.go+2−1
internal/tui/components/chat/editor/editor.go+36−18
@@ -162,9 +162,7 @@ func (m *editorCmp) send() tea.Cmd {
162162}
163163
164164func (m *editorCmp) repositionCompletions() tea.Msg {
165− cur := m.textarea.Cursor()
166− x := cur.X + m.x // adjust for padding
167− y := cur.Y + m.y + 1
165+ x, y := m.completionsPosition()
168166 return completions.RepositionCompletionsMsg{X: x, Y: y}
169167}
170168
@@ -191,32 +189,37 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
191189 return m, nil
192190 }
193191 if item, ok := msg.Value.(FileCompletionItem); ok {
192+ word := m.textarea.Word()
194193 // If the selected item is a file, insert its path into the textarea
195194 value := m.textarea.Value()
196− value = value[:m.completionsStartIndex]
197− value += item.Path
195+ value = value[:m.completionsStartIndex] + // Remove the current query
196+ item.Path + // Insert the file path
197+ value[m.completionsStartIndex+len(word):] // Append the rest of the value
198+ // XXX: This will always move the cursor to the end of the textarea.
198199 m.textarea.SetValue(value)
200+ m.textarea.MoveToEnd()
199201 if !msg.Insert {
200202 m.isCompletionsOpen = false
201203 m.currentQuery = ""
202204 m.completionsStartIndex = 0
203205 }
204− return m, nil
205206 }
206207 case openEditorMsg:
207208 m.textarea.SetValue(msg.Text)
208209 m.textarea.MoveToEnd()
209210 case tea.KeyPressMsg:
211+ cur := m.textarea.Cursor()
212+ curIdx := m.textarea.Width()*cur.Y + cur.X
210213 switch {
211214 // Completions
212215 case msg.String() == "/" && !m.isCompletionsOpen &&
213− // only show if beginning of prompt, or if previous char is a space:
214− (len(m.textarea.Value()) == 0 || m.textarea.Value()[len(m.textarea.Value())-1] == ' '):
216+ // only show if beginning of prompt, or if previous char is a space or newline:
217+ (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
215218 m.isCompletionsOpen = true
216219 m.currentQuery = ""
217− m.completionsStartIndex = len(m.textarea.Value())
220+ m.completionsStartIndex = curIdx
218221 cmds = append(cmds, m.startCompletions)
219− case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
222+ case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
220223 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
221224 }
222225 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
@@ -253,6 +256,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
253256 }
254257 if key.Matches(msg, m.keyMap.Newline) {
255258 m.textarea.InsertRune('\n')
259+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
256260 }
257261 // Handle Enter key
258262 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) {
284288 // XXX: wont' work if editing in the middle of the field.
285289 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
286290 m.currentQuery = word[1:]
291+ x, y := m.completionsPosition()
292+ x -= len(m.currentQuery)
287293 m.isCompletionsOpen = true
288− cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
289− Query: m.currentQuery,
290− Reopen: m.isCompletionsOpen,
291− }))
292− } else {
294+ cmds = append(cmds,
295+ util.CmdHandler(completions.FilterCompletionsMsg{
296+ Query: m.currentQuery,
297+ Reopen: m.isCompletionsOpen,
298+ X: x,
299+ Y: y,
300+ }),
301+ )
302+ } else if m.isCompletionsOpen {
293303 m.isCompletionsOpen = false
294304 m.currentQuery = ""
295305 m.completionsStartIndex = 0
@@ -302,6 +312,16 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
302312 return m, tea.Batch(cmds...)
303313}
304314
315+func (m *editorCmp) completionsPosition() (int, int) {
316+ cur := m.textarea.Cursor()
317+ if cur == nil {
318+ return m.x, m.y + 1 // adjust for padding
319+ }
320+ x := cur.X + m.x
321+ y := cur.Y + m.y + 1 // adjust for padding
322+ return x, y
323+}
324+
305325func (m *editorCmp) Cursor() *tea.Cursor {
306326 cursor := m.textarea.Cursor()
307327 if cursor != nil {
@@ -382,9 +402,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
382402 })
383403 }
384404
385− cur := m.textarea.Cursor()
386− x := cur.X + m.x // adjust for padding
387− y := cur.Y + m.y + 1
405+ x, y := m.completionsPosition()
388406 return completions.OpenCompletionsMsg{
389407 Completions: completionItems,
390408 X: x,
internal/tui/components/completions/completions.go+20−9
@@ -27,6 +27,8 @@ type OpenCompletionsMsg struct {
2727type FilterCompletionsMsg struct {
2828 Query string // The query to filter completions
2929 Reopen bool
30+ X int // X position for the completions popup
31+ Y int // Y position for the completions popup
3032}
3133
3234type RepositionCompletionsMsg struct {
@@ -165,6 +167,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165167 }
166168 case RepositionCompletionsMsg:
167169 c.x, c.y = msg.X, msg.Y
170+ c.adjustPosition()
168171 case CloseCompletionsMsg:
169172 c.open = false
170173 return c, util.CmdHandler(CompletionsClosedMsg{})
@@ -216,15 +219,9 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
216219 cmds = append(cmds, c.list.Filter(msg.Query))
217220 items := c.list.Items()
218221 itemsLen := len(items)
219− width := listWidth(items)
220− c.lastWidth = c.width
221− if c.x < 0 || width < c.lastWidth {
222− c.x = c.xorig
223− } else if c.x+width >= c.wWidth {
224− c.x = c.wWidth - width - 1
225− }
226− c.width = width
227− c.height = max(min(maxCompletionsHeight, itemsLen), 1)
222+ c.xorig = msg.X
223+ c.x, c.y = msg.X, msg.Y
224+ c.adjustPosition()
228225 cmds = append(cmds, c.list.SetSize(c.width, c.height))
229226 if itemsLen == 0 {
230227 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
@@ -237,6 +234,20 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
237234 return c, nil
238235}
239236
237+func (c *completionsCmp) adjustPosition() {
238+ items := c.list.Items()
239+ itemsLen := len(items)
240+ width := listWidth(items)
241+ c.lastWidth = c.width
242+ if c.x < 0 || width < c.lastWidth {
243+ c.x = c.xorig
244+ } else if c.x+width >= c.wWidth {
245+ c.x = c.wWidth - width - 1
246+ }
247+ c.width = width
248+ c.height = max(min(maxCompletionsHeight, itemsLen), 1)
249+}
250+
240251// View implements Completions.
241252func (c *completionsCmp) View() string {
242253 if !c.open || len(c.list.Items()) == 0 {
internal/tui/tui.go+2−1
@@ -116,7 +116,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116116 return a, a.handleWindowResize(msg.Width, msg.Height)
117117
118118 // Completions messages
119− case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
119+ case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
120+ completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
120121 u, completionCmd := a.completions.Update(msg)
121122 a.completions = u.(completions.Completions)
122123 return a, completionCmd