feat(ui): chat: add navigation and keybindings

Ayman Bagabas created

Change summary

internal/ui/list/list.go  | 43 ++++++++++++++++++
internal/ui/model/chat.go | 60 +++++++++++++++++++++++++-
internal/ui/model/keys.go | 51 ++++++++++++++++++++++
internal/ui/model/ui.go   | 93 ++++++++++++++++++++++++++++++----------
4 files changed, 220 insertions(+), 27 deletions(-)

Detailed changes

internal/ui/list/list.go 🔗

@@ -818,6 +818,49 @@ func (l *List) TotalHeight() int {
 	return l.totalHeight
 }
 
+// SelectFirstInView selects the first item that is fully visible in the viewport.
+func (l *List) SelectFirstInView() {
+	l.ensureBuilt()
+
+	viewportStart := l.offset
+	viewportEnd := l.offset + l.height
+
+	for i, item := range l.items {
+		pos, ok := l.itemPositions[item.ID()]
+		if !ok {
+			continue
+		}
+
+		// Check if item is fully within viewport bounds
+		if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
+			l.SetSelectedIndex(i)
+			return
+		}
+	}
+}
+
+// SelectLastInView selects the last item that is fully visible in the viewport.
+func (l *List) SelectLastInView() {
+	l.ensureBuilt()
+
+	viewportStart := l.offset
+	viewportEnd := l.offset + l.height
+
+	for i := len(l.items) - 1; i >= 0; i-- {
+		item := l.items[i]
+		pos, ok := l.itemPositions[item.ID()]
+		if !ok {
+			continue
+		}
+
+		// Check if item is fully within viewport bounds
+		if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
+			l.SetSelectedIndex(i)
+			return
+		}
+	}
+}
+
 // SelectedItemInView returns true if the selected item is currently visible in the viewport.
 func (l *List) SelectedItemInView() bool {
 	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {

internal/ui/model/chat.go 🔗

@@ -77,9 +77,6 @@ func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem {
 
 // ChatMessageItem represents a chat message item in the chat UI.
 type ChatMessageItem struct {
-	list.BaseFocusable
-	list.BaseHighlightable
-
 	item list.Item
 	msg  message.Message
 }
@@ -169,6 +166,43 @@ func (c *ChatMessageItem) ID() string {
 	return c.item.ID()
 }
 
+// Blur implements list.Focusable.
+func (c *ChatMessageItem) Blur() {
+	if blurable, ok := c.item.(list.Focusable); ok {
+		blurable.Blur()
+	}
+}
+
+// Focus implements list.Focusable.
+func (c *ChatMessageItem) Focus() {
+	if focusable, ok := c.item.(list.Focusable); ok {
+		focusable.Focus()
+	}
+}
+
+// IsFocused implements list.Focusable.
+func (c *ChatMessageItem) IsFocused() bool {
+	if focusable, ok := c.item.(list.Focusable); ok {
+		return focusable.IsFocused()
+	}
+	return false
+}
+
+// GetHighlight implements list.Highlightable.
+func (c *ChatMessageItem) GetHighlight() (startLine int, startCol int, endLine int, endCol int) {
+	if highlightable, ok := c.item.(list.Highlightable); ok {
+		return highlightable.GetHighlight()
+	}
+	return 0, 0, 0, 0
+}
+
+// SetHighlight implements list.Highlightable.
+func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
+	if highlightable, ok := c.item.(list.Highlightable); ok {
+		highlightable.SetHighlight(startLine, startCol, endLine, endCol)
+	}
+}
+
 // Chat represents the chat UI model that handles chat interactions and
 // messages.
 type Chat struct {
@@ -279,6 +313,26 @@ func (m *Chat) SelectNext() {
 	m.list.SelectNext()
 }
 
+// SelectFirst selects the first message in the chat list.
+func (m *Chat) SelectFirst() {
+	m.list.SelectFirst()
+}
+
+// SelectLast selects the last message in the chat list.
+func (m *Chat) SelectLast() {
+	m.list.SelectLast()
+}
+
+// SelectFirstInView selects the first message currently in view.
+func (m *Chat) SelectFirstInView() {
+	m.list.SelectFirstInView()
+}
+
+// SelectLastInView selects the last message currently in view.
+func (m *Chat) SelectLastInView() {
+	m.list.SelectLastInView()
+}
+
 // HandleMouseDown handles mouse down events for the chat component.
 func (m *Chat) HandleMouseDown(x, y int) {
 	m.list.HandleMouseDown(x, y)

internal/ui/model/keys.go 🔗

@@ -23,6 +23,16 @@ type KeyMap struct {
 		Cancel        key.Binding
 		Tab           key.Binding
 		Details       key.Binding
+		Down          key.Binding
+		Up            key.Binding
+		DownOneItem   key.Binding
+		UpOneItem     key.Binding
+		PageDown      key.Binding
+		PageUp        key.Binding
+		HalfPageDown  key.Binding
+		HalfPageUp    key.Binding
+		Home          key.Binding
+		End           key.Binding
 	}
 
 	Initialize struct {
@@ -135,6 +145,47 @@ func DefaultKeyMap() KeyMap {
 		key.WithHelp("ctrl+d", "toggle details"),
 	)
 
+	km.Chat.Down = key.NewBinding(
+		key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
+		key.WithHelp("↓", "down"),
+	)
+	km.Chat.Up = key.NewBinding(
+		key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
+		key.WithHelp("↑", "up"),
+	)
+	km.Chat.UpOneItem = key.NewBinding(
+		key.WithKeys("shift+up", "K"),
+		key.WithHelp("shift+↑", "up one item"),
+	)
+	km.Chat.DownOneItem = key.NewBinding(
+		key.WithKeys("shift+down", "J"),
+		key.WithHelp("shift+↓", "down one item"),
+	)
+	km.Chat.HalfPageDown = key.NewBinding(
+		key.WithKeys("d"),
+		key.WithHelp("d", "half page down"),
+	)
+	km.Chat.PageDown = key.NewBinding(
+		key.WithKeys("pgdown", " ", "f"),
+		key.WithHelp("f/pgdn", "page down"),
+	)
+	km.Chat.PageUp = key.NewBinding(
+		key.WithKeys("pgup", "b"),
+		key.WithHelp("b/pgup", "page up"),
+	)
+	km.Chat.HalfPageUp = key.NewBinding(
+		key.WithKeys("u"),
+		key.WithHelp("u", "half page up"),
+	)
+	km.Chat.Home = key.NewBinding(
+		key.WithKeys("g", "home"),
+		key.WithHelp("g", "home"),
+	)
+	km.Chat.End = key.NewBinding(
+		key.WithKeys("G", "end"),
+		key.WithHelp("G", "end"),
+	)
+
 	km.Initialize.Yes = key.NewBinding(
 		key.WithKeys("y", "Y"),
 		key.WithHelp("y", "yes"),

internal/ui/model/ui.go 🔗

@@ -7,6 +7,7 @@ import (
 	"os"
 	"slices"
 	"strings"
+	"time"
 
 	"charm.land/bubbles/v2/help"
 	"charm.land/bubbles/v2/key"
@@ -177,7 +178,10 @@ func (m *UI) Init() tea.Cmd {
 	}
 	allSessions, _ := m.com.App.Sessions.List(context.Background())
 	if len(allSessions) > 0 {
-		cmds = append(cmds, m.loadSession(allSessions[0].ID))
+		cmds = append(cmds, func() tea.Msg {
+			time.Sleep(2 * time.Second)
+			return m.loadSession(allSessions[0].ID)()
+		})
 	}
 	return tea.Batch(cmds...)
 }
@@ -300,10 +304,30 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 		return m.updateDialogs(msg)
 	}
 
-	switch {
-	case key.Matches(msg, m.keyMap.Tab):
-		switch m.state {
-		case uiChat:
+	handleGlobalKeys := func(msg tea.KeyPressMsg) {
+		switch {
+		case key.Matches(msg, m.keyMap.Tab):
+		case key.Matches(msg, m.keyMap.Help):
+			m.help.ShowAll = !m.help.ShowAll
+			m.updateLayoutAndSize()
+		case key.Matches(msg, m.keyMap.Quit):
+			if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
+				m.dialog.AddDialog(dialog.NewQuit(m.com))
+				return
+			}
+		case key.Matches(msg, m.keyMap.Commands):
+			// TODO: Implement me
+		case key.Matches(msg, m.keyMap.Models):
+			// TODO: Implement me
+		case key.Matches(msg, m.keyMap.Sessions):
+			// TODO: Implement me
+		}
+	}
+
+	switch m.state {
+	case uiChat:
+		switch {
+		case key.Matches(msg, m.keyMap.Tab):
 			if m.focus == uiFocusMain {
 				m.focus = uiFocusEditor
 				cmds = append(cmds, m.textarea.Focus())
@@ -314,26 +338,47 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 				m.chat.Focus()
 				m.chat.SetSelectedIndex(m.chat.Len() - 1)
 			}
+		case key.Matches(msg, m.keyMap.Chat.Up):
+			m.chat.ScrollBy(-1)
+			if !m.chat.SelectedItemInView() {
+				m.chat.SelectPrev()
+				m.chat.ScrollToSelected()
+			}
+		case key.Matches(msg, m.keyMap.Chat.Down):
+			m.chat.ScrollBy(1)
+			if !m.chat.SelectedItemInView() {
+				m.chat.SelectNext()
+				m.chat.ScrollToSelected()
+			}
+		case key.Matches(msg, m.keyMap.Chat.UpOneItem):
+			m.chat.SelectPrev()
+			m.chat.ScrollToSelected()
+		case key.Matches(msg, m.keyMap.Chat.DownOneItem):
+			m.chat.SelectNext()
+			m.chat.ScrollToSelected()
+		case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
+			m.chat.ScrollBy(-m.chat.Height() / 2)
+			m.chat.SelectFirstInView()
+		case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
+			m.chat.ScrollBy(m.chat.Height() / 2)
+			m.chat.SelectLastInView()
+		case key.Matches(msg, m.keyMap.Chat.PageUp):
+			m.chat.ScrollBy(-m.chat.Height())
+			m.chat.SelectFirstInView()
+		case key.Matches(msg, m.keyMap.Chat.PageDown):
+			m.chat.ScrollBy(m.chat.Height())
+			m.chat.SelectLastInView()
+		case key.Matches(msg, m.keyMap.Chat.Home):
+			m.chat.ScrollToTop()
+			m.chat.SelectFirst()
+		case key.Matches(msg, m.keyMap.Chat.End):
+			m.chat.ScrollToBottom()
+			m.chat.SelectLast()
+		default:
+			handleGlobalKeys(msg)
 		}
-	case key.Matches(msg, m.keyMap.Help):
-		m.help.ShowAll = !m.help.ShowAll
-		m.updateLayoutAndSize()
-		return cmds
-	case key.Matches(msg, m.keyMap.Quit):
-		if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
-			m.dialog.AddDialog(dialog.NewQuit(m.com))
-			return
-		}
-		return cmds
-	case key.Matches(msg, m.keyMap.Commands):
-		// TODO: Implement me
-		return cmds
-	case key.Matches(msg, m.keyMap.Models):
-		// TODO: Implement me
-		return cmds
-	case key.Matches(msg, m.keyMap.Sessions):
-		// TODO: Implement me
-		return cmds
+	default:
+		handleGlobalKeys(msg)
 	}
 
 	cmds = append(cmds, m.updateFocused(msg)...)