From ca3e06a43cd8bd111952f174b27e945cc6a9e3d3 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 2 Dec 2025 16:12:24 -0500 Subject: [PATCH] feat(ui): chat: add navigation and keybindings --- 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(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 8928cb0f011fffe2e1f70c3a34b7a6bad6212f67..6fd116c5a106d5e26db58189e91d2e4b60da956d 100644 --- a/internal/ui/list/list.go +++ b/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) { diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 28272d8b6356ebd7de39d888f6de886b1c2e3b0e..55fd9f58d161f1caa30ecb55b22e142d8e911aae 100644 --- a/internal/ui/model/chat.go +++ b/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) diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 143f1d623828b56baa3fd43b86ca74ea2fbd82b9..d146e53853e7a9d6dd234e8b911a636b16e8a170 100644 --- a/internal/ui/model/keys.go +++ b/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"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d11f25149c4bd7f97e0c256ee232042aed8a3634..a8a38ade3f2b967ef3acc0f7cee9e644e917ecd2 100644 --- a/internal/ui/model/ui.go +++ b/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)...)