diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index f76daab7157b87bba374fa56372c0b25c650dab6..f9667787430423e01ee0eab6be59bf8ce76f3b70 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -18,6 +18,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/uicmd" "github.com/charmbracelet/crush/internal/uiutil" ) @@ -32,24 +33,6 @@ type SendMsg struct { Attachments []message.Attachment } -// Messages for commands -type ( - SwitchSessionsMsg struct{} - NewSessionsMsg struct{} - SwitchModelMsg struct{} - QuitMsg struct{} - OpenFilePickerMsg struct{} - ToggleHelpMsg struct{} - ToggleCompactModeMsg struct{} - ToggleThinkingMsg struct{} - OpenReasoningDialogMsg struct{} - OpenExternalEditorMsg struct{} - ToggleYoloModeMsg struct{} - CompactMsg struct { - SessionID string - } -) - // Commands represents a dialog that shows available commands. type Commands struct { com *common.Common @@ -149,10 +132,12 @@ func (c *Commands) ID() string { } // Update implements Dialog. -func (c *Commands) Update(msg tea.Msg) tea.Cmd { +func (c *Commands) Update(msg tea.Msg) tea.Msg { switch msg := msg.(type) { case tea.KeyPressMsg: switch { + case key.Matches(msg, c.keyMap.Close): + return CloseMsg{} case key.Matches(msg, c.keyMap.Previous): c.list.Focus() c.list.SelectPrev() @@ -175,8 +160,10 @@ func (c *Commands) Update(msg tea.Msg) tea.Cmd { default: var cmd tea.Cmd c.input, cmd = c.input.Update(msg) - // Update the list filter - c.list.SetFilter(c.input.Value()) + value := c.input.Value() + c.list.SetFilter(value) + c.list.ScrollToTop() + c.list.SetSelected(0) return cmd } } @@ -195,14 +182,17 @@ func (c *Commands) ReloadMCPPrompts() tea.Cmd { // Cursor returns the cursor position relative to the dialog. func (c *Commands) Cursor() *tea.Cursor { - return c.input.Cursor() + return InputCursor(c.com.Styles, c.input.Cursor()) } -// View implements [Dialog]. -func (c *Commands) View() string { - t := c.com.Styles +// radioView generates the command type selector radio buttons. +func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string { + if !hasUserCmds && !hasMCPPrompts { + return "" + } + selectedFn := func(t uicmd.CommandType) string { - if t == c.selected { + if t == selected { return "◉ " + t.String() } return "○ " + t.String() @@ -211,46 +201,27 @@ func (c *Commands) View() string { parts := []string{ selectedFn(uicmd.SystemCommands), } - if len(c.userCmds) > 0 { + if hasUserCmds { parts = append(parts, selectedFn(uicmd.UserCommands)) } - if c.mcpPrompts.Len() > 0 { + if hasMCPPrompts { parts = append(parts, selectedFn(uicmd.MCPPrompts)) } radio := strings.Join(parts, " ") - radio = t.Dialog.Commands.CommandTypeSelector.Render(radio) - if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 { - radio = " " + radio - } + return t.Dialog.Commands.CommandTypeSelector.Render(radio) +} +// View implements [Dialog]. +func (c *Commands) View() string { + t := c.com.Styles + radio := radioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0) titleStyle := t.Dialog.Title - helpStyle := t.Dialog.HelpView dialogStyle := t.Dialog.View.Width(c.width) - inputStyle := t.Dialog.InputPrompt - helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize()) - headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio - title := titleStyle.Render(header) - help := helpStyle.Render(c.help.View(c)) - listContent := c.list.Render() - if nlines := lipgloss.Height(listContent); nlines < c.list.Height() { - // pad the list content to avoid jumping when navigating - listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines)) - } - - content := strings.Join([]string{ - title, - "", - inputStyle.Render(c.input.View()), - "", - c.list.Render(), - "", - help, - }, "\n") - - return dialogStyle.Render(content) + return HeaderInputListHelpView(t, c.width, c.list.Height(), header, + c.input.View(), c.list.Render(), c.help.View(c)) } // ShortHelp implements [help.KeyMap]. @@ -316,7 +287,9 @@ func (c *Commands) setCommandType(commandType uicmd.CommandType) { } c.list.SetItems(commandItems...) - // Reset selection and filter + c.list.SetSelected(0) + c.list.SetFilter("") + c.list.ScrollToTop() c.list.SetSelected(0) c.input.SetValue("") } @@ -485,7 +458,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { Description: "Quit", Shortcut: "ctrl+c", Handler: func(cmd uicmd.Command) tea.Cmd { - return uiutil.CmdHandler(QuitMsg{}) + return uiutil.CmdHandler(tea.QuitMsg{}) }, }, }...) diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go new file mode 100644 index 0000000000000000000000000000000000000000..48234281f304208b9e1a30c575fab342ceb5e57a --- /dev/null +++ b/internal/ui/dialog/common.go @@ -0,0 +1,56 @@ +package dialog + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// InputCursor adjusts the cursor position for an input field within a dialog. +func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor { + if cur != nil { + titleStyle := t.Dialog.Title + dialogStyle := t.Dialog.View + inputStyle := t.Dialog.InputPrompt + // Adjust cursor position to account for dialog layout + cur.X += inputStyle.GetBorderLeftSize() + + inputStyle.GetMarginLeft() + + inputStyle.GetPaddingLeft() + + dialogStyle.GetBorderLeftSize() + + dialogStyle.GetPaddingLeft() + + dialogStyle.GetMarginLeft() + cur.Y += titleStyle.GetVerticalFrameSize() + + inputStyle.GetBorderTopSize() + + inputStyle.GetMarginTop() + + inputStyle.GetPaddingTop() + + inputStyle.GetBorderBottomSize() + + inputStyle.GetMarginBottom() + + inputStyle.GetPaddingBottom() + + dialogStyle.GetPaddingTop() + + dialogStyle.GetMarginTop() + + dialogStyle.GetBorderTopSize() + } + return cur +} + +// HeaderInputListHelpView generates a view for dialogs with a header, input, +// list, and help sections. +func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string { + titleStyle := t.Dialog.Title + helpStyle := t.Dialog.HelpView + dialogStyle := t.Dialog.View.Width(width) + inputStyle := t.Dialog.InputPrompt + helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize()) + listStyle := t.Dialog.List.Height(listHeight) + listContent := listStyle.Render(list) + + content := strings.Join([]string{ + titleStyle.Render(header), + inputStyle.Render(input), + listContent, + helpStyle.Render(help), + }, "\n") + + return dialogStyle.Render(content) +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 6dc30a9263cf99be1bed0037fd331135f61826b3..ec9472dc892c1a095abb284897966ac92b259dc4 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -17,7 +17,7 @@ var CloseKey = key.NewBinding( // Dialog is a component that can be displayed on top of the UI. type Dialog interface { ID() string - Update(msg tea.Msg) tea.Cmd + Update(msg tea.Msg) tea.Msg View() string } @@ -71,6 +71,14 @@ func (d *Overlay) RemoveDialog(dialogID string) { } } +// RemoveFrontDialog removes the front dialog from the stack. +func (d *Overlay) RemoveFrontDialog() { + if len(d.dialogs) == 0 { + return + } + d.removeDialog(len(d.dialogs) - 1) +} + // Dialog returns the dialog with the specified ID, or nil if not found. func (d *Overlay) Dialog(dialogID string) Dialog { for _, dialog := range d.dialogs { @@ -81,6 +89,14 @@ func (d *Overlay) Dialog(dialogID string) Dialog { return nil } +// DialogLast returns the front dialog, or nil if there are no dialogs. +func (d *Overlay) DialogLast() Dialog { + if len(d.dialogs) == 0 { + return nil + } + return d.dialogs[len(d.dialogs)-1] +} + // BringToFront brings the dialog with the specified ID to the front. func (d *Overlay) BringToFront(dialogID string) { for i, dialog := range d.dialogs { @@ -94,38 +110,40 @@ func (d *Overlay) BringToFront(dialogID string) { } // Update handles dialog updates. -func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) { +func (d *Overlay) Update(msg tea.Msg) tea.Msg { if len(d.dialogs) == 0 { - return d, nil + return nil } idx := len(d.dialogs) - 1 // active dialog is the last one dialog := d.dialogs[idx] - switch msg := msg.(type) { - case tea.KeyPressMsg: - if key.Matches(msg, CloseKey) { - // Close the current dialog - d.removeDialog(idx) - return d, nil - } + if dialog == nil { + return nil } - if cmd := dialog.Update(msg); cmd != nil { - // Close the current dialog - d.removeDialog(idx) - return d, cmd + return dialog.Update(msg) +} + +// CenterPosition calculates the centered position for the dialog. +func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle { + dialog := d.Dialog(dialogID) + if dialog == nil { + return uv.Rectangle{} } + return d.centerPositionView(area, dialog.View()) +} - return d, nil +func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle { + viewWidth := lipgloss.Width(view) + viewHeight := lipgloss.Height(view) + return common.CenterRect(area, viewWidth, viewHeight) } // Draw renders the overlay and its dialogs. func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) { for _, dialog := range d.dialogs { view := dialog.View() - viewWidth := lipgloss.Width(view) - viewHeight := lipgloss.Height(view) - center := common.CenterRect(area, viewWidth, viewHeight) + center := d.centerPositionView(area, view) if area.Overlaps(center) { uv.NewStyledString(view).Draw(scr, center) } diff --git a/internal/ui/dialog/messages.go b/internal/ui/dialog/messages.go new file mode 100644 index 0000000000000000000000000000000000000000..b3981ce382ea8712e93bd075600b3455748cb579 --- /dev/null +++ b/internal/ui/dialog/messages.go @@ -0,0 +1,34 @@ +package dialog + +import ( + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/session" +) + +// CloseMsg is a message to close the current dialog. +type CloseMsg struct{} + +// QuitMsg is a message to quit the application. +type QuitMsg = tea.QuitMsg + +// SessionSelectedMsg is a message indicating a session has been selected. +type SessionSelectedMsg struct { + Session session.Session +} + +// Messages for commands +type ( + SwitchSessionsMsg struct{} + NewSessionsMsg struct{} + SwitchModelMsg struct{} + OpenFilePickerMsg struct{} + ToggleHelpMsg struct{} + ToggleCompactModeMsg struct{} + ToggleThinkingMsg struct{} + OpenReasoningDialogMsg struct{} + OpenExternalEditorMsg struct{} + ToggleYoloModeMsg struct{} + CompactMsg struct { + SessionID string + } +) diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 4891d62d1f7c9933f2e231ba50c42aa71ca0f2c0..8f687571c8789901ac7cadc464cc4aecf53698db 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -60,22 +60,24 @@ func (*Quit) ID() string { } // Update implements [Model]. -func (q *Quit) Update(msg tea.Msg) tea.Cmd { +func (q *Quit) Update(msg tea.Msg) tea.Msg { switch msg := msg.(type) { case tea.KeyPressMsg: switch { + case key.Matches(msg, q.keyMap.Close): + return CloseMsg{} case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): q.selectedNo = !q.selectedNo - return nil + return CloseMsg{} case key.Matches(msg, q.keyMap.EnterSpace): if !q.selectedNo { - return tea.Quit + return QuitMsg{} } - return nil + return CloseMsg{} case key.Matches(msg, q.keyMap.Yes): - return tea.Quit + return QuitMsg{} case key.Matches(msg, q.keyMap.No, q.keyMap.Close): - return nil + return CloseMsg{} } } diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 26fa34d9ace6119da928249ba7753b0cd600ea4f..016bd1a8f79373296ecbec7acf2e36b97dae8ff5 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -1,13 +1,10 @@ package dialog import ( - "strings" - "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" @@ -34,11 +31,6 @@ type Session struct { var _ Dialog = (*Session)(nil) -// SessionSelectedMsg is a message sent when a session is selected. -type SessionSelectedMsg struct { - Session session.Session -} - // NewSessions creates a new Session dialog. func NewSessions(com *common.Common, sessions ...session.Session) *Session { s := new(Session) @@ -73,11 +65,6 @@ func NewSessions(com *common.Common, sessions ...session.Session) *Session { return s } -// Cursor returns the cursor position relative to the dialog. -func (s *Session) Cursor() *tea.Cursor { - return s.input.Cursor() -} - // SetSize sets the size of the dialog. func (s *Session) SetSize(width, height int) { s.width = width @@ -94,10 +81,12 @@ func (s *Session) ID() string { } // Update implements Dialog. -func (s *Session) Update(msg tea.Msg) tea.Cmd { +func (s *Session) Update(msg tea.Msg) tea.Msg { switch msg := msg.(type) { case tea.KeyPressMsg: switch { + case key.Matches(msg, s.keyMap.Close): + return CloseMsg{} case key.Matches(msg, s.keyMap.Previous): s.list.Focus() s.list.SelectPrev() @@ -109,48 +98,36 @@ func (s *Session) Update(msg tea.Msg) tea.Cmd { case key.Matches(msg, s.keyMap.Select): if item := s.list.SelectedItem(); item != nil { sessionItem := item.(*SessionItem) - return SessionSelectCmd(sessionItem.Session) + return SessionSelectedMsg{sessionItem.Session} } default: var cmd tea.Cmd s.input, cmd = s.input.Update(msg) - s.list.SetFilter(s.input.Value()) + value := s.input.Value() + s.list.SetFilter(value) + s.list.ScrollToTop() + s.list.SetSelected(0) return cmd } } return nil } +// Cursor returns the cursor position relative to the dialog. +func (s *Session) Cursor() *tea.Cursor { + return InputCursor(s.com.Styles, s.input.Cursor()) +} + // View implements [Dialog]. func (s *Session) View() string { titleStyle := s.com.Styles.Dialog.Title - helpStyle := s.com.Styles.Dialog.HelpView dialogStyle := s.com.Styles.Dialog.View.Width(s.width) - inputStyle := s.com.Styles.Dialog.InputPrompt - helpStyle = helpStyle.Width(s.width - dialogStyle.GetHorizontalFrameSize()) - listContent := s.list.Render() - if nlines := lipgloss.Height(listContent); nlines < s.list.Height() { - // pad the list content to avoid jumping when navigating - listContent += strings.Repeat("\n", max(0, s.list.Height()-nlines)) - } + header := common.DialogTitle(s.com.Styles, "Switch Session", + max(0, s.width-dialogStyle.GetHorizontalFrameSize()- + titleStyle.GetHorizontalFrameSize())) - content := strings.Join([]string{ - titleStyle.Render( - common.DialogTitle( - s.com.Styles, - "Switch Session", - max(0, s.width- - dialogStyle.GetHorizontalFrameSize()- - titleStyle.GetHorizontalFrameSize()))), - "", - inputStyle.Render(s.input.View()), - "", - listContent, - "", - helpStyle.Render(s.help.View(s)), - }, "\n") - - return dialogStyle.Render(content) + return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header, + s.input.View(), s.list.Render(), s.help.View(s)) } // ShortHelp implements [help.KeyMap]. @@ -181,10 +158,3 @@ func (s *Session) FullHelp() [][]key.Binding { } return m } - -// SessionSelectCmd creates a command that sends a SessionSelectMsg. -func SessionSelectCmd(s session.Session) tea.Cmd { - return func() tea.Msg { - return SessionSelectedMsg{Session: s} - } -} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cb5e9c6af77dec28e4c21d82311fe906641d6d80..2755410947ac41e62951c42c11e514c40727abce 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -20,11 +20,11 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/uiutil" "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" @@ -194,12 +194,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // TODO: Get. Rid. Of. Magic numbers! sessions.SetSize(min(120, m.width-8), 30) m.dialog.AddDialog(sessions) - case dialog.SessionSelectedMsg: - m.dialog.RemoveDialog(dialog.SessionsID) - cmds = append(cmds, - m.loadSession(msg.Session.ID), - m.loadSessionFiles(msg.Session.ID), - ) case sessionLoadedMsg: m.state = uiChat m.session = &msg.sess @@ -330,15 +324,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyPressMsg: cmds = append(cmds, m.handleKeyPressMsg(msg)...) - - // Command dialog messages - // TODO: Properly structure and handle these messages - case dialog.ToggleYoloModeMsg: - m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests()) - m.dialog.RemoveDialog(dialog.CommandsID) - case dialog.SwitchSessionsMsg: - cmds = append(cmds, m.loadSessionsCmd) - m.dialog.RemoveDialog(dialog.CommandsID) } // This logic gets triggered on any message type, but should it? @@ -384,7 +369,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return true } switch { - case key.Matches(msg, m.keyMap.Tab): case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll m.updateLayoutAndSize() @@ -400,7 +384,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } commands, err := dialog.NewCommands(m.com, sessionID) if err != nil { - cmds = append(cmds, util.ReportError(err)) + cmds = append(cmds, uiutil.ReportError(err)) } else { // TODO: Get. Rid. Of. Magic numbers! commands.SetSize(min(120, m.width-8), 30) @@ -427,64 +411,101 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { return cmds } - updatedDialog, cmd := m.dialog.Update(msg) - m.dialog = updatedDialog - if cmd != nil { - cmds = append(cmds, cmd) + msg := m.dialog.Update(msg) + if msg == nil { + return cmds } + + switch msg := msg.(type) { + // Generic dialog messages + case dialog.CloseMsg: + m.dialog.RemoveFrontDialog() + // Session dialog messages + case dialog.SessionSelectedMsg: + m.dialog.RemoveDialog(dialog.SessionsID) + cmds = append(cmds, + m.loadSession(msg.Session.ID), + m.loadSessionFiles(msg.Session.ID), + ) + // Command dialog messages + case dialog.ToggleYoloModeMsg: + m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests()) + m.dialog.RemoveDialog(dialog.CommandsID) + case dialog.SwitchSessionsMsg: + cmds = append(cmds, m.loadSessionsCmd) + m.dialog.RemoveDialog(dialog.CommandsID) + case dialog.CompactMsg: + err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) + if err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + } + case dialog.ToggleHelpMsg: + m.help.ShowAll = !m.help.ShowAll + case dialog.QuitMsg: + cmds = append(cmds, tea.Quit) + } + return cmds } 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()) - m.chat.Blur() - } else { + switch m.focus { + case uiFocusEditor: + switch { + case key.Matches(msg, m.keyMap.Tab): m.focus = uiFocusMain m.textarea.Blur() m.chat.Focus() m.chat.SetSelected(m.chat.Len() - 1) + default: + handleGlobalKeys(msg) } - case key.Matches(msg, m.keyMap.Chat.Up): - m.chat.ScrollBy(-1) - if !m.chat.SelectedItemInView() { + case uiFocusMain: + switch { + case key.Matches(msg, m.keyMap.Tab): + m.focus = uiFocusEditor + cmds = append(cmds, m.textarea.Focus()) + m.chat.Blur() + 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.Down): - m.chat.ScrollBy(1) - if !m.chat.SelectedItemInView() { + 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.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) } @@ -588,11 +609,29 @@ func (m *UI) Cursor() *tea.Cursor { // Don't show cursor if editor is not visible return nil } - if m.focus == uiFocusEditor && m.textarea.Focused() { - cur := m.textarea.Cursor() - cur.X++ // Adjust for app margins - cur.Y += m.layout.editor.Min.Y - return cur + if m.dialog.HasDialogs() { + if front := m.dialog.DialogLast(); front != nil { + c, ok := front.(uiutil.Cursor) + if ok { + cur := c.Cursor() + if cur != nil { + pos := m.dialog.CenterPosition(m.layout.area, front.ID()) + cur.X += pos.Min.X + cur.Y += pos.Min.Y + return cur + } + } + } + return nil + } + switch m.focus { + case uiFocusEditor: + if m.textarea.Focused() { + cur := m.textarea.Cursor() + cur.X++ // Adjust for app margins + cur.Y += m.layout.editor.Min.Y + return cur + } } return nil } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 8eb3562d0221a281bfe559b22e70266da97b56b4..7690bdb704de6338754d931373fcf29924efc6a8 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -277,6 +277,8 @@ type Styles struct { SelectedItem lipgloss.Style InputPrompt lipgloss.Style + List lipgloss.Style + Commands struct { CommandTypeSelector lipgloss.Style } @@ -909,7 +911,9 @@ func DefaultStyles() Styles { s.Dialog.Help.FullSeparator = base.Foreground(border) s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase) s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase) - s.Dialog.InputPrompt = base.Padding(0, 1) + s.Dialog.InputPrompt = base.Margin(1, 1) + + s.Dialog.List = base.Margin(0, 0, 1, 0) s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted)