on main help sign in
Merge pull request #294 from charmbracelet/charm-434

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

commit4a4099734b043ae79a20b10d29ca884a77b72b00 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 25 Jul 2025, 13:32 (1 year ago) parent05a3e9a97290c8
4 files changed+125−47
internal/tui/components/chat/editor/editor.go+42−15 internal/tui/components/completions/completions.go+78−20 internal/tui/page/chat/chat.go+3−1 internal/tui/tui.go+2−11
internal/tui/components/chat/editor/editor.go+42−15
@@ -161,10 +161,17 @@ func (m *editorCmp) send() tea.Cmd {
161161 )
162162}
163163
164+func (m *editorCmp) repositionCompletions() tea.Msg {
165+ x, y := m.completionsPosition()
166+ return completions.RepositionCompletionsMsg{X: x, Y: y}
167+}
168+
164169func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165170 var cmd tea.Cmd
166171 var cmds []tea.Cmd
167172 switch msg := msg.(type) {
173+ case tea.WindowSizeMsg:
174+ return m, m.repositionCompletions
168175 case filepicker.FilePickedMsg:
169176 if len(m.attachments) >= maxAttachments {
170177 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) {
182189 return m, nil
183190 }
184191 if item, ok := msg.Value.(FileCompletionItem); ok {
192+ word := m.textarea.Word()
185193 // If the selected item is a file, insert its path into the textarea
186194 value := m.textarea.Value()
187− value = value[:m.completionsStartIndex]
188− 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.
189199 m.textarea.SetValue(value)
200+ m.textarea.MoveToEnd()
190201 if !msg.Insert {
191202 m.isCompletionsOpen = false
192203 m.currentQuery = ""
193204 m.completionsStartIndex = 0
194205 }
195− return m, nil
196206 }
197207 case openEditorMsg:
198208 m.textarea.SetValue(msg.Text)
199209 m.textarea.MoveToEnd()
200210 case tea.KeyPressMsg:
211+ cur := m.textarea.Cursor()
212+ curIdx := m.textarea.Width()*cur.Y + cur.X
201213 switch {
202214 // Completions
203215 case msg.String() == "/" && !m.isCompletionsOpen &&
204− // only show if beginning of prompt, or if previous char is a space:
205− (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]))):
206218 m.isCompletionsOpen = true
207219 m.currentQuery = ""
208− m.completionsStartIndex = len(m.textarea.Value())
220+ m.completionsStartIndex = curIdx
209221 cmds = append(cmds, m.startCompletions)
210− case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
222+ case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
211223 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
212224 }
213225 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
@@ -244,6 +256,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244256 }
245257 if key.Matches(msg, m.keyMap.Newline) {
246258 m.textarea.InsertRune('\n')
259+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
247260 }
248261 // Handle Enter key
249262 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) {
275288 // XXX: wont' work if editing in the middle of the field.
276289 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
277290 m.currentQuery = word[1:]
291+ x, y := m.completionsPosition()
292+ x -= len(m.currentQuery)
278293 m.isCompletionsOpen = true
279− cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
280− Query: m.currentQuery,
281− Reopen: m.isCompletionsOpen,
282− }))
283− } 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 {
284303 m.isCompletionsOpen = false
285304 m.currentQuery = ""
286305 m.completionsStartIndex = 0
@@ -293,6 +312,16 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
293312 return m, tea.Batch(cmds...)
294313}
295314
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+
296325func (m *editorCmp) Cursor() *tea.Cursor {
297326 cursor := m.textarea.Cursor()
298327 if cursor != nil {
@@ -373,9 +402,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
373402 })
374403 }
375404
376− cur := m.textarea.Cursor()
377− x := cur.X + m.x // adjust for padding
378− y := cur.Y + m.y + 1
405+ x, y := m.completionsPosition()
379406 return completions.OpenCompletionsMsg{
380407 Completions: completionItems,
381408 X: x,
internal/tui/components/completions/completions.go+78−20
@@ -27,6 +27,12 @@ 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
32+}
33+
34+type RepositionCompletionsMsg struct {
35+ X, Y int
3036}
3137
3238type CompletionsClosedMsg struct{}
@@ -51,18 +57,24 @@ type Completions interface {
5157}
5258
5359type completionsCmp struct {
54− width int
55− height int // Height of the completions component`
56− x int // X position for the completions popup
57− y int // Y position for the completions popup
58− open bool // Indicates if the completions are open
59− keyMap KeyMap
60+ wWidth int // The window width
61+ wHeight int // The window height
62+ width int
63+ lastWidth int
64+ height int // Height of the completions component`
65+ x, xorig int // X position for the completions popup
66+ y int // Y position for the completions popup
67+ open bool // Indicates if the completions are open
68+ keyMap KeyMap
6069
6170 list list.ListModel
6271 query string // The current filter query
6372}
6473
65−const maxCompletionsWidth = 80 // Maximum width for the completions popup
74+const (
75+ maxCompletionsWidth = 80 // Maximum width for the completions popup
76+ minCompletionsWidth = 20 // Minimum width for the completions popup
77+)
6678
6779func New() Completions {
6880 completionsKeyMap := DefaultKeyMap()
@@ -83,7 +95,7 @@ func New() Completions {
8395 )
8496 return &completionsCmp{
8597 width: 0,
86− height: 0,
98+ height: maxCompletionsHeight,
8799 list: l,
88100 query: "",
89101 keyMap: completionsKeyMap,
@@ -102,8 +114,7 @@ func (c *completionsCmp) Init() tea.Cmd {
102114func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103115 switch msg := msg.(type) {
104116 case tea.WindowSizeMsg:
105− c.width = min(msg.Width-c.x, maxCompletionsWidth)
106− c.height = min(msg.Height-c.y, 15)
117+ c.wWidth, c.wHeight = msg.Width, msg.Height
107118 return c, nil
108119 case tea.KeyPressMsg:
109120 switch {
@@ -154,13 +165,16 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
154165 case key.Matches(msg, c.keyMap.Cancel):
155166 return c, util.CmdHandler(CloseCompletionsMsg{})
156167 }
168+ case RepositionCompletionsMsg:
169+ c.x, c.y = msg.X, msg.Y
170+ c.adjustPosition()
157171 case CloseCompletionsMsg:
158172 c.open = false
159173 return c, util.CmdHandler(CompletionsClosedMsg{})
160174 case OpenCompletionsMsg:
161175 c.open = true
162176 c.query = ""
163− c.x = msg.X
177+ c.x, c.xorig = msg.X, msg.X
164178 c.y = msg.Y
165179 items := []util.Model{}
166180 t := styles.CurrentTheme()
@@ -168,10 +182,18 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
168182 item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
169183 items = append(items, item)
170184 }
171− c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
185+ width := listWidth(items)
186+ if len(items) == 0 {
187+ width = listWidth(c.list.Items())
188+ }
189+ if c.x+width >= c.wWidth {
190+ c.x = c.wWidth - width - 1
191+ }
192+ c.width = width
193+ c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
172194 return c, tea.Batch(
173− c.list.SetSize(c.width, c.height),
174195 c.list.SetItems(items),
196+ c.list.SetSize(c.width, c.height),
175197 util.CmdHandler(CompletionsOpenedMsg{}),
176198 )
177199 case FilterCompletionsMsg:
@@ -195,8 +217,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195217 c.query = msg.Query
196218 var cmds []tea.Cmd
197219 cmds = append(cmds, c.list.Filter(msg.Query))
198− itemsLen := len(c.list.Items())
199− c.height = max(min(maxCompletionsHeight, itemsLen), 1)
220+ items := c.list.Items()
221+ itemsLen := len(items)
222+ c.xorig = msg.X
223+ c.x, c.y = msg.X, msg.Y
224+ c.adjustPosition()
200225 cmds = append(cmds, c.list.SetSize(c.width, c.height))
201226 if itemsLen == 0 {
202227 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
@@ -209,21 +234,54 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
209234 return c, nil
210235}
211236
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+
212251// View implements Completions.
213252func (c *completionsCmp) View() string {
214253 if !c.open || len(c.list.Items()) == 0 {
215254 return ""
216255 }
217256
218− return c.style().Render(c.list.View())
219−}
220−
221−func (c *completionsCmp) style() lipgloss.Style {
222257 t := styles.CurrentTheme()
223− return t.S().Base.
258+ style := t.S().Base.
224259 Width(c.width).
225260 Height(c.height).
226261 Background(t.BgSubtle)
262+
263+ return style.Render(c.list.View())
264+}
265+
266+// listWidth returns the width of the last 10 items in the list, which is used
267+// to determine the width of the completions popup.
268+// Note this only works for [completionItemCmp] items.
269+func listWidth[T any](items []T) int {
270+ var width int
271+ if len(items) == 0 {
272+ return width
273+ }
274+
275+ for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
276+ item, ok := any(items[i]).(*completionItemCmp)
277+ if !ok {
278+ continue
279+ }
280+ itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
281+ width = max(width, itemWidth)
282+ }
283+
284+ return width
227285}
228286
229287func (c *completionsCmp) Open() bool {
internal/tui/page/chat/chat.go+3−1
@@ -165,7 +165,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165165 p.keyboardEnhancements = msg
166166 return p, nil
167167 case tea.WindowSizeMsg:
168− return p, p.SetSize(msg.Width, msg.Height)
168+ u, cmd := p.editor.Update(msg)
169+ p.editor = u.(editor.Editor)
170+ return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
169171 case CancelTimerExpiredMsg:
170172 p.isCanceling = false
171173 return p, nil
internal/tui/tui.go+2−11
@@ -118,19 +118,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118118 return a, a.handleWindowResize(msg.Width, msg.Height)
119119
120120 // Completions messages
121− case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
121+ case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
122+ completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
122123 u, completionCmd := a.completions.Update(msg)
123124 a.completions = u.(completions.Completions)
124− switch msg := msg.(type) {
125− case completions.OpenCompletionsMsg:
126− x, _ := a.completions.Position()
127− if a.completions.Width()+x >= a.wWidth {
128− // Adjust X position to fit in the window.
129− msg.X = a.wWidth - a.completions.Width() - 1
130− u, completionCmd = a.completions.Update(msg)
131− a.completions = u.(completions.Completions)
132− }
133− }
134125 return a, completionCmd
135126
136127 // Dialog messages