on main help sign in
feat(ui): chat: add navigation and keybindings
ca3e06aAyman Bagabas
internal/ui/list/list.go+43
@@ -818,6 +818,49 @@ func (l *List) TotalHeight() int {
818 return l.totalHeight
818 return l.totalHeight
819}
819}
820
820
821+// SelectFirstInView selects the first item that is fully visible in the viewport.
822+func (l *List) SelectFirstInView() {
823+ l.ensureBuilt()
824+
825+ viewportStart := l.offset
826+ viewportEnd := l.offset + l.height
827+
828+ for i, item := range l.items {
829+ pos, ok := l.itemPositions[item.ID()]
830+ if !ok {
831+ continue
832+ }
833+
834+ // Check if item is fully within viewport bounds
835+ if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
836+ l.SetSelectedIndex(i)
837+ return
838+ }
839+ }
840+}
841+
842+// SelectLastInView selects the last item that is fully visible in the viewport.
843+func (l *List) SelectLastInView() {
844+ l.ensureBuilt()
845+
846+ viewportStart := l.offset
847+ viewportEnd := l.offset + l.height
848+
849+ for i := len(l.items) - 1; i >= 0; i-- {
850+ item := l.items[i]
851+ pos, ok := l.itemPositions[item.ID()]
852+ if !ok {
853+ continue
854+ }
855+
856+ // Check if item is fully within viewport bounds
857+ if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
858+ l.SetSelectedIndex(i)
859+ return
860+ }
861+ }
862+}
863+
821// SelectedItemInView returns true if the selected item is currently visible in the viewport.
864// SelectedItemInView returns true if the selected item is currently visible in the viewport.
822func (l *List) SelectedItemInView() bool {
865func (l *List) SelectedItemInView() bool {
823 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
866 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
internal/ui/model/chat.go+57−3
@@ -77,9 +77,6 @@ func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem {
77
77
78// ChatMessageItem represents a chat message item in the chat UI.
78// ChatMessageItem represents a chat message item in the chat UI.
79type ChatMessageItem struct {
79type ChatMessageItem struct {
80− list.BaseFocusable
81− list.BaseHighlightable
82−
83 item list.Item
80 item list.Item
84 msg message.Message
81 msg message.Message
85}
82}
@@ -169,6 +166,43 @@ func (c *ChatMessageItem) ID() string {
169 return c.item.ID()
166 return c.item.ID()
170}
167}
171
168
169+// Blur implements list.Focusable.
170+func (c *ChatMessageItem) Blur() {
171+ if blurable, ok := c.item.(list.Focusable); ok {
172+ blurable.Blur()
173+ }
174+}
175+
176+// Focus implements list.Focusable.
177+func (c *ChatMessageItem) Focus() {
178+ if focusable, ok := c.item.(list.Focusable); ok {
179+ focusable.Focus()
180+ }
181+}
182+
183+// IsFocused implements list.Focusable.
184+func (c *ChatMessageItem) IsFocused() bool {
185+ if focusable, ok := c.item.(list.Focusable); ok {
186+ return focusable.IsFocused()
187+ }
188+ return false
189+}
190+
191+// GetHighlight implements list.Highlightable.
192+func (c *ChatMessageItem) GetHighlight() (startLine int, startCol int, endLine int, endCol int) {
193+ if highlightable, ok := c.item.(list.Highlightable); ok {
194+ return highlightable.GetHighlight()
195+ }
196+ return 0, 0, 0, 0
197+}
198+
199+// SetHighlight implements list.Highlightable.
200+func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
201+ if highlightable, ok := c.item.(list.Highlightable); ok {
202+ highlightable.SetHighlight(startLine, startCol, endLine, endCol)
203+ }
204+}
205+
172// Chat represents the chat UI model that handles chat interactions and
206// Chat represents the chat UI model that handles chat interactions and
173// messages.
207// messages.
174type Chat struct {
208type Chat struct {
@@ -279,6 +313,26 @@ func (m *Chat) SelectNext() {
279 m.list.SelectNext()
313 m.list.SelectNext()
280}
314}
281
315
316+// SelectFirst selects the first message in the chat list.
317+func (m *Chat) SelectFirst() {
318+ m.list.SelectFirst()
319+}
320+
321+// SelectLast selects the last message in the chat list.
322+func (m *Chat) SelectLast() {
323+ m.list.SelectLast()
324+}
325+
326+// SelectFirstInView selects the first message currently in view.
327+func (m *Chat) SelectFirstInView() {
328+ m.list.SelectFirstInView()
329+}
330+
331+// SelectLastInView selects the last message currently in view.
332+func (m *Chat) SelectLastInView() {
333+ m.list.SelectLastInView()
334+}
335+
282// HandleMouseDown handles mouse down events for the chat component.
336// HandleMouseDown handles mouse down events for the chat component.
283func (m *Chat) HandleMouseDown(x, y int) {
337func (m *Chat) HandleMouseDown(x, y int) {
284 m.list.HandleMouseDown(x, y)
338 m.list.HandleMouseDown(x, y)
internal/ui/model/keys.go+51
@@ -23,6 +23,16 @@ type KeyMap struct {
23 Cancel key.Binding
23 Cancel key.Binding
24 Tab key.Binding
24 Tab key.Binding
25 Details key.Binding
25 Details key.Binding
26+ Down key.Binding
27+ Up key.Binding
28+ DownOneItem key.Binding
29+ UpOneItem key.Binding
30+ PageDown key.Binding
31+ PageUp key.Binding
32+ HalfPageDown key.Binding
33+ HalfPageUp key.Binding
34+ Home key.Binding
35+ End key.Binding
26 }
36 }
27
37
28 Initialize struct {
38 Initialize struct {
@@ -135,6 +145,47 @@ func DefaultKeyMap() KeyMap {
135 key.WithHelp("ctrl+d", "toggle details"),
145 key.WithHelp("ctrl+d", "toggle details"),
136 )
146 )
137
147
148+ km.Chat.Down = key.NewBinding(
149+ key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
150+ key.WithHelp("↓", "down"),
151+ )
152+ km.Chat.Up = key.NewBinding(
153+ key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
154+ key.WithHelp("↑", "up"),
155+ )
156+ km.Chat.UpOneItem = key.NewBinding(
157+ key.WithKeys("shift+up", "K"),
158+ key.WithHelp("shift+↑", "up one item"),
159+ )
160+ km.Chat.DownOneItem = key.NewBinding(
161+ key.WithKeys("shift+down", "J"),
162+ key.WithHelp("shift+↓", "down one item"),
163+ )
164+ km.Chat.HalfPageDown = key.NewBinding(
165+ key.WithKeys("d"),
166+ key.WithHelp("d", "half page down"),
167+ )
168+ km.Chat.PageDown = key.NewBinding(
169+ key.WithKeys("pgdown", " ", "f"),
170+ key.WithHelp("f/pgdn", "page down"),
171+ )
172+ km.Chat.PageUp = key.NewBinding(
173+ key.WithKeys("pgup", "b"),
174+ key.WithHelp("b/pgup", "page up"),
175+ )
176+ km.Chat.HalfPageUp = key.NewBinding(
177+ key.WithKeys("u"),
178+ key.WithHelp("u", "half page up"),
179+ )
180+ km.Chat.Home = key.NewBinding(
181+ key.WithKeys("g", "home"),
182+ key.WithHelp("g", "home"),
183+ )
184+ km.Chat.End = key.NewBinding(
185+ key.WithKeys("G", "end"),
186+ key.WithHelp("G", "end"),
187+ )
188+
138 km.Initialize.Yes = key.NewBinding(
189 km.Initialize.Yes = key.NewBinding(
139 key.WithKeys("y", "Y"),
190 key.WithKeys("y", "Y"),
140 key.WithHelp("y", "yes"),
191 key.WithHelp("y", "yes"),
internal/ui/model/ui.go+69−24
@@ -7,6 +7,7 @@ import (
7 "os"
7 "os"
8 "slices"
8 "slices"
9 "strings"
9 "strings"
10+ "time"
10
11
11 "charm.land/bubbles/v2/help"
12 "charm.land/bubbles/v2/help"
12 "charm.land/bubbles/v2/key"
13 "charm.land/bubbles/v2/key"
@@ -177,7 +178,10 @@ func (m *UI) Init() tea.Cmd {
177 }
178 }
178 allSessions, _ := m.com.App.Sessions.List(context.Background())
179 allSessions, _ := m.com.App.Sessions.List(context.Background())
179 if len(allSessions) > 0 {
180 if len(allSessions) > 0 {
181+ cmds = append(cmds, func() tea.Msg {
182+ time.Sleep(2 * time.Second)
180− cmds = append(cmds, m.loadSession(allSessions[0].ID))
183+ return m.loadSession(allSessions[0].ID)()
184+ })
181 }
185 }
182 return tea.Batch(cmds...)
186 return tea.Batch(cmds...)
183}
187}
@@ -300,10 +304,30 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
300 return m.updateDialogs(msg)
304 return m.updateDialogs(msg)
301 }
305 }
302
306
307+ handleGlobalKeys := func(msg tea.KeyPressMsg) {
303− switch {
308+ switch {
304− case key.Matches(msg, m.keyMap.Tab):
309+ case key.Matches(msg, m.keyMap.Tab):
310+ case key.Matches(msg, m.keyMap.Help):
311+ m.help.ShowAll = !m.help.ShowAll
312+ m.updateLayoutAndSize()
313+ case key.Matches(msg, m.keyMap.Quit):
314+ if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
315+ m.dialog.AddDialog(dialog.NewQuit(m.com))
316+ return
317+ }
318+ case key.Matches(msg, m.keyMap.Commands):
319+ // TODO: Implement me
320+ case key.Matches(msg, m.keyMap.Models):
321+ // TODO: Implement me
322+ case key.Matches(msg, m.keyMap.Sessions):
323+ // TODO: Implement me
324+ }
325+ }
326+
305− switch m.state {
327+ switch m.state {
306− case uiChat:
328+ case uiChat:
329+ switch {
330+ case key.Matches(msg, m.keyMap.Tab):
307 if m.focus == uiFocusMain {
331 if m.focus == uiFocusMain {
308 m.focus = uiFocusEditor
332 m.focus = uiFocusEditor
309 cmds = append(cmds, m.textarea.Focus())
333 cmds = append(cmds, m.textarea.Focus())
@@ -314,26 +338,47 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
314 m.chat.Focus()
338 m.chat.Focus()
315 m.chat.SetSelectedIndex(m.chat.Len() - 1)
339 m.chat.SetSelectedIndex(m.chat.Len() - 1)
316 }
340 }
341+ case key.Matches(msg, m.keyMap.Chat.Up):
342+ m.chat.ScrollBy(-1)
343+ if !m.chat.SelectedItemInView() {
344+ m.chat.SelectPrev()
345+ m.chat.ScrollToSelected()
346+ }
347+ case key.Matches(msg, m.keyMap.Chat.Down):
348+ m.chat.ScrollBy(1)
349+ if !m.chat.SelectedItemInView() {
350+ m.chat.SelectNext()
351+ m.chat.ScrollToSelected()
352+ }
353+ case key.Matches(msg, m.keyMap.Chat.UpOneItem):
354+ m.chat.SelectPrev()
355+ m.chat.ScrollToSelected()
356+ case key.Matches(msg, m.keyMap.Chat.DownOneItem):
357+ m.chat.SelectNext()
358+ m.chat.ScrollToSelected()
359+ case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
360+ m.chat.ScrollBy(-m.chat.Height() / 2)
361+ m.chat.SelectFirstInView()
362+ case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
363+ m.chat.ScrollBy(m.chat.Height() / 2)
364+ m.chat.SelectLastInView()
365+ case key.Matches(msg, m.keyMap.Chat.PageUp):
366+ m.chat.ScrollBy(-m.chat.Height())
367+ m.chat.SelectFirstInView()
368+ case key.Matches(msg, m.keyMap.Chat.PageDown):
369+ m.chat.ScrollBy(m.chat.Height())
370+ m.chat.SelectLastInView()
371+ case key.Matches(msg, m.keyMap.Chat.Home):
372+ m.chat.ScrollToTop()
373+ m.chat.SelectFirst()
374+ case key.Matches(msg, m.keyMap.Chat.End):
375+ m.chat.ScrollToBottom()
376+ m.chat.SelectLast()
377+ default:
378+ handleGlobalKeys(msg)
317 }
379 }
318− case key.Matches(msg, m.keyMap.Help):
380+ default:
319− m.help.ShowAll = !m.help.ShowAll
381+ handleGlobalKeys(msg)
320− m.updateLayoutAndSize()
321− return cmds
322− case key.Matches(msg, m.keyMap.Quit):
323− if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
324− m.dialog.AddDialog(dialog.NewQuit(m.com))
325− return
326− }
327− return cmds
328− case key.Matches(msg, m.keyMap.Commands):
329− // TODO: Implement me
330− return cmds
331− case key.Matches(msg, m.keyMap.Models):
332− // TODO: Implement me
333− return cmds
334− case key.Matches(msg, m.keyMap.Sessions):
335− // TODO: Implement me
336− return cmds
337 }
382 }
338
383
339 cmds = append(cmds, m.updateFocused(msg)...)
384 cmds = append(cmds, m.updateFocused(msg)...)