From 97290c82f2e9d72808fe1634226da51b5a06b216 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 24 Jul 2025 13:59:43 -0400 Subject: [PATCH] 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. --- internal/tui/components/chat/editor/editor.go | 54 ++++++++++++------- .../tui/components/completions/completions.go | 29 ++++++---- internal/tui/tui.go | 3 +- 3 files changed, 58 insertions(+), 28 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 7f06e69a388e10a49cbb792d0f4b8d231613eb3b..4e5f0bc431eb466cea5c6c7d436234c7a5e8531b 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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, diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index ab29d900010bb2a80e4e2b7d6135e44f6486769c..aad5dc8c83c163712a4d9b56e7a6442ce2380f25 100644 --- a/internal/tui/components/completions/completions.go +++ b/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 { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1cdc0c38243da39b2bd8c8eb276beea78f1dd37f..770e7b26945e9bf7109f3076e1ad95a1f24aa51a 100644 --- a/internal/tui/tui.go +++ b/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