on main help sign in
Merge pull request #294 from charmbracelet/charm-434
4a40997Kujtim Hoxha
internal/tui/components/chat/editor/editor.go+42−15
@@ -161,10 +161,17 @@ func (m *editorCmp) send() tea.Cmd {
161 )
161 )
162}
162}
163
163
164+func (m *editorCmp) repositionCompletions() tea.Msg {
165+ x, y := m.completionsPosition()
166+ return completions.RepositionCompletionsMsg{X: x, Y: y}
167+}
168+
164func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165 var cmd tea.Cmd
170 var cmd tea.Cmd
166 var cmds []tea.Cmd
171 var cmds []tea.Cmd
167 switch msg := msg.(type) {
172 switch msg := msg.(type) {
173+ case tea.WindowSizeMsg:
174+ return m, m.repositionCompletions
168 case filepicker.FilePickedMsg:
175 case filepicker.FilePickedMsg:
169 if len(m.attachments) >= maxAttachments {
176 if len(m.attachments) >= maxAttachments {
170 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
177 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) {
182 return m, nil
189 return m, nil
183 }
190 }
184 if item, ok := msg.Value.(FileCompletionItem); ok {
191 if item, ok := msg.Value.(FileCompletionItem); ok {
192+ word := m.textarea.Word()
185 // If the selected item is a file, insert its path into the textarea
193 // If the selected item is a file, insert its path into the textarea
186 value := m.textarea.Value()
194 value := m.textarea.Value()
187− value = value[:m.completionsStartIndex]
195+ value = value[:m.completionsStartIndex] + // Remove the current query
188− value += item.Path
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.
189 m.textarea.SetValue(value)
199 m.textarea.SetValue(value)
200+ m.textarea.MoveToEnd()
190 if !msg.Insert {
201 if !msg.Insert {
191 m.isCompletionsOpen = false
202 m.isCompletionsOpen = false
192 m.currentQuery = ""
203 m.currentQuery = ""
193 m.completionsStartIndex = 0
204 m.completionsStartIndex = 0
194 }
205 }
195− return m, nil
196 }
206 }
197 case openEditorMsg:
207 case openEditorMsg:
198 m.textarea.SetValue(msg.Text)
208 m.textarea.SetValue(msg.Text)
199 m.textarea.MoveToEnd()
209 m.textarea.MoveToEnd()
200 case tea.KeyPressMsg:
210 case tea.KeyPressMsg:
211+ cur := m.textarea.Cursor()
212+ curIdx := m.textarea.Width()*cur.Y + cur.X
201 switch {
213 switch {
202 // Completions
214 // Completions
203 case msg.String() == "/" && !m.isCompletionsOpen &&
215 case msg.String() == "/" && !m.isCompletionsOpen &&
204− // only show if beginning of prompt, or if previous char is a space:
216+ // only show if beginning of prompt, or if previous char is a space or newline:
205− (len(m.textarea.Value()) == 0 || m.textarea.Value()[len(m.textarea.Value())-1] == ' '):
217+ (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
206 m.isCompletionsOpen = true
218 m.isCompletionsOpen = true
207 m.currentQuery = ""
219 m.currentQuery = ""
208− m.completionsStartIndex = len(m.textarea.Value())
220+ m.completionsStartIndex = curIdx
209 cmds = append(cmds, m.startCompletions)
221 cmds = append(cmds, m.startCompletions)
210− case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
222+ case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
211 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
223 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
212 }
224 }
213 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
225 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
@@ -244,6 +256,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244 }
256 }
245 if key.Matches(msg, m.keyMap.Newline) {
257 if key.Matches(msg, m.keyMap.Newline) {
246 m.textarea.InsertRune('\n')
258 m.textarea.InsertRune('\n')
259+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
247 }
260 }
248 // Handle Enter key
261 // Handle Enter key
249 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
262 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) {
275 // XXX: wont' work if editing in the middle of the field.
288 // XXX: wont' work if editing in the middle of the field.
276 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
289 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
277 m.currentQuery = word[1:]
290 m.currentQuery = word[1:]
291+ x, y := m.completionsPosition()
292+ x -= len(m.currentQuery)
278 m.isCompletionsOpen = true
293 m.isCompletionsOpen = true
294+ cmds = append(cmds,
279− cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
295+ util.CmdHandler(completions.FilterCompletionsMsg{
280− Query: m.currentQuery,
296+ Query: m.currentQuery,
281− Reopen: m.isCompletionsOpen,
297+ Reopen: m.isCompletionsOpen,
298+ X: x,
299+ Y: y,
282− }))
300+ }),
283− } else {
301+ )
302+ } else if m.isCompletionsOpen {
284 m.isCompletionsOpen = false
303 m.isCompletionsOpen = false
285 m.currentQuery = ""
304 m.currentQuery = ""
286 m.completionsStartIndex = 0
305 m.completionsStartIndex = 0
@@ -293,6 +312,16 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
293 return m, tea.Batch(cmds...)
312 return m, tea.Batch(cmds...)
294}
313}
295
314
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+
296func (m *editorCmp) Cursor() *tea.Cursor {
325func (m *editorCmp) Cursor() *tea.Cursor {
297 cursor := m.textarea.Cursor()
326 cursor := m.textarea.Cursor()
298 if cursor != nil {
327 if cursor != nil {
@@ -373,9 +402,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
373 })
402 })
374 }
403 }
375
404
376− cur := m.textarea.Cursor()
405+ x, y := m.completionsPosition()
377− x := cur.X + m.x // adjust for padding
378− y := cur.Y + m.y + 1
379 return completions.OpenCompletionsMsg{
406 return completions.OpenCompletionsMsg{
380 Completions: completionItems,
407 Completions: completionItems,
381 X: x,
408 X: x,
internal/tui/components/completions/completions.go+78−20
@@ -27,6 +27,12 @@ type OpenCompletionsMsg struct {
27type FilterCompletionsMsg struct {
27type FilterCompletionsMsg struct {
28 Query string // The query to filter completions
28 Query string // The query to filter completions
29 Reopen bool
29 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
30}
36}
31
37
32type CompletionsClosedMsg struct{}
38type CompletionsClosedMsg struct{}
@@ -51,18 +57,24 @@ type Completions interface {
51}
57}
52
58
53type completionsCmp struct {
59type completionsCmp struct {
54− width int
60+ wWidth int // The window width
61+ wHeight int // The window height
62+ width int
63+ lastWidth int
55− height int // Height of the completions component`
64+ height int // Height of the completions component`
56− x int // X position for the completions popup
65+ x, xorig int // X position for the completions popup
57− y int // Y position for the completions popup
66+ y int // Y position for the completions popup
58− open bool // Indicates if the completions are open
67+ open bool // Indicates if the completions are open
59− keyMap KeyMap
68+ keyMap KeyMap
60
69
61 list list.ListModel
70 list list.ListModel
62 query string // The current filter query
71 query string // The current filter query
63}
72}
64
73
74+const (
65−const maxCompletionsWidth = 80 // Maximum width for the completions popup
75+ maxCompletionsWidth = 80 // Maximum width for the completions popup
76+ minCompletionsWidth = 20 // Minimum width for the completions popup
77+)
66
78
67func New() Completions {
79func New() Completions {
68 completionsKeyMap := DefaultKeyMap()
80 completionsKeyMap := DefaultKeyMap()
@@ -83,7 +95,7 @@ func New() Completions {
83 )
95 )
84 return &completionsCmp{
96 return &completionsCmp{
85 width: 0,
97 width: 0,
86− height: 0,
98+ height: maxCompletionsHeight,
87 list: l,
99 list: l,
88 query: "",
100 query: "",
89 keyMap: completionsKeyMap,
101 keyMap: completionsKeyMap,
@@ -102,8 +114,7 @@ func (c *completionsCmp) Init() tea.Cmd {
102func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103 switch msg := msg.(type) {
115 switch msg := msg.(type) {
104 case tea.WindowSizeMsg:
116 case tea.WindowSizeMsg:
105− c.width = min(msg.Width-c.x, maxCompletionsWidth)
117+ c.wWidth, c.wHeight = msg.Width, msg.Height
106− c.height = min(msg.Height-c.y, 15)
107 return c, nil
118 return c, nil
108 case tea.KeyPressMsg:
119 case tea.KeyPressMsg:
109 switch {
120 switch {
@@ -154,13 +165,16 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
154 case key.Matches(msg, c.keyMap.Cancel):
165 case key.Matches(msg, c.keyMap.Cancel):
155 return c, util.CmdHandler(CloseCompletionsMsg{})
166 return c, util.CmdHandler(CloseCompletionsMsg{})
156 }
167 }
168+ case RepositionCompletionsMsg:
169+ c.x, c.y = msg.X, msg.Y
170+ c.adjustPosition()
157 case CloseCompletionsMsg:
171 case CloseCompletionsMsg:
158 c.open = false
172 c.open = false
159 return c, util.CmdHandler(CompletionsClosedMsg{})
173 return c, util.CmdHandler(CompletionsClosedMsg{})
160 case OpenCompletionsMsg:
174 case OpenCompletionsMsg:
161 c.open = true
175 c.open = true
162 c.query = ""
176 c.query = ""
163− c.x = msg.X
177+ c.x, c.xorig = msg.X, msg.X
164 c.y = msg.Y
178 c.y = msg.Y
165 items := []util.Model{}
179 items := []util.Model{}
166 t := styles.CurrentTheme()
180 t := styles.CurrentTheme()
@@ -168,10 +182,18 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
168 item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
182 item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
169 items = append(items, item)
183 items = append(items, item)
170 }
184 }
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
171− c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
193+ c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
172 return c, tea.Batch(
194 return c, tea.Batch(
173− c.list.SetSize(c.width, c.height),
174 c.list.SetItems(items),
195 c.list.SetItems(items),
196+ c.list.SetSize(c.width, c.height),
175 util.CmdHandler(CompletionsOpenedMsg{}),
197 util.CmdHandler(CompletionsOpenedMsg{}),
176 )
198 )
177 case FilterCompletionsMsg:
199 case FilterCompletionsMsg:
@@ -195,8 +217,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195 c.query = msg.Query
217 c.query = msg.Query
196 var cmds []tea.Cmd
218 var cmds []tea.Cmd
197 cmds = append(cmds, c.list.Filter(msg.Query))
219 cmds = append(cmds, c.list.Filter(msg.Query))
198− itemsLen := len(c.list.Items())
220+ items := c.list.Items()
199− c.height = max(min(maxCompletionsHeight, itemsLen), 1)
221+ itemsLen := len(items)
222+ c.xorig = msg.X
223+ c.x, c.y = msg.X, msg.Y
224+ c.adjustPosition()
200 cmds = append(cmds, c.list.SetSize(c.width, c.height))
225 cmds = append(cmds, c.list.SetSize(c.width, c.height))
201 if itemsLen == 0 {
226 if itemsLen == 0 {
202 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
227 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
@@ -209,21 +234,54 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
209 return c, nil
234 return c, nil
210}
235}
211
236
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+
212// View implements Completions.
251// View implements Completions.
213func (c *completionsCmp) View() string {
252func (c *completionsCmp) View() string {
214 if !c.open || len(c.list.Items()) == 0 {
253 if !c.open || len(c.list.Items()) == 0 {
215 return ""
254 return ""
216 }
255 }
217
256
218− return c.style().Render(c.list.View())
219−}
220−
221−func (c *completionsCmp) style() lipgloss.Style {
222 t := styles.CurrentTheme()
257 t := styles.CurrentTheme()
223− return t.S().Base.
258+ style := t.S().Base.
224 Width(c.width).
259 Width(c.width).
225 Height(c.height).
260 Height(c.height).
226 Background(t.BgSubtle)
261 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
227}
285}
228
286
229func (c *completionsCmp) Open() bool {
287func (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) {
165 p.keyboardEnhancements = msg
165 p.keyboardEnhancements = msg
166 return p, nil
166 return p, nil
167 case tea.WindowSizeMsg:
167 case tea.WindowSizeMsg:
168+ u, cmd := p.editor.Update(msg)
169+ p.editor = u.(editor.Editor)
168− return p, p.SetSize(msg.Width, msg.Height)
170+ return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
169 case CancelTimerExpiredMsg:
171 case CancelTimerExpiredMsg:
170 p.isCanceling = false
172 p.isCanceling = false
171 return p, nil
173 return p, nil
internal/tui/tui.go+2−11
@@ -118,19 +118,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118 return a, a.handleWindowResize(msg.Width, msg.Height)
118 return a, a.handleWindowResize(msg.Width, msg.Height)
119
119
120 // Completions messages
120 // Completions messages
121− case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
121+ case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
122+ completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
122 u, completionCmd := a.completions.Update(msg)
123 u, completionCmd := a.completions.Update(msg)
123 a.completions = u.(completions.Completions)
124 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− }
134 return a, completionCmd
125 return a, completionCmd
135
126
136 // Dialog messages
127 // Dialog messages