From 1f16850fba6465bcc336a1309eedc076ac493b3b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 13:20:18 +0200 Subject: [PATCH] chore: improve help --- internal/tui/components/chat/editor/editor.go | 3 - internal/tui/components/chat/editor/keys.go | 9 +- internal/tui/components/chat/splash/splash.go | 13 +- .../components/core/{helpers.go => core.go} | 28 ++ .../tui/components/core/layout/container.go | 263 ------------ internal/tui/components/core/layout/layout.go | 7 +- internal/tui/components/core/layout/split.go | 375 ------------------ internal/tui/components/core/list/keys.go | 11 +- internal/tui/components/core/list/list.go | 4 + internal/tui/components/core/status/status.go | 3 +- internal/tui/keys.go | 63 +-- internal/tui/page/chat/chat.go | 240 ++++++++++- internal/tui/tui.go | 10 +- 13 files changed, 301 insertions(+), 728 deletions(-) rename internal/tui/components/core/{helpers.go => core.go} (88%) delete mode 100644 internal/tui/components/core/layout/container.go delete mode 100644 internal/tui/components/core/layout/split.go diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 038249277ffa572755de160f0d404525def803b7..1c2989683b8fb8c8cc77fbaac5c49ec8248d91e4 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -157,9 +157,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { - case tea.KeyboardEnhancementsMsg: - m.keyMap.keyboard = msg - return m, nil case filepicker.FilePickedMsg: if len(m.attachments) >= maxAttachments { return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments)) diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index aa2ba1ee44ce7fe9928e7e812acea3898a7496e5..d0243fd3dae2fc0d13942833566510ad6a7546eb 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/internal/tui/components/chat/editor/keys.go @@ -2,7 +2,6 @@ package editor import ( "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" ) type EditorKeyMap struct { @@ -10,8 +9,6 @@ type EditorKeyMap struct { SendMessage key.Binding OpenEditor key.Binding Newline key.Binding - - keyboard tea.KeyboardEnhancementsMsg } func DefaultEditorKeyMap() EditorKeyMap { @@ -40,15 +37,11 @@ func DefaultEditorKeyMap() EditorKeyMap { // KeyBindings implements layout.KeyMapProvider func (k EditorKeyMap) KeyBindings() []key.Binding { - newline := k.Newline - if k.keyboard.SupportsKeyDisambiguation() { - newline.SetHelp("shift+enter", newline.Help().Desc) - } return []key.Binding{ k.AddFile, k.SendMessage, k.OpenEditor, - newline, + k.Newline, } } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 2a70f17d7388f35f8859cd64fd49fdf3cf605054..66c1c596fd762fccaacdf23541a4ed6b2a05d035 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -32,6 +32,9 @@ type Splash interface { SetOnboarding(bool) // SetProjectInit controls whether the splash shows project initialization prompt SetProjectInit(bool) + + // Showing API key input + IsShowingAPIKey() bool } const ( @@ -126,9 +129,11 @@ func (s *splashCmp) Init() tea.Cmd { // SetSize implements SplashPage. func (s *splashCmp) SetSize(width int, height int) tea.Cmd { - s.width = width s.height = height - s.logoRendered = s.logoBlock() + if width != s.width { + s.width = width + s.logoRendered = s.logoBlock() + } listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title listWidth := min(60, width-(SplashScreenPaddingX*2)) @@ -626,3 +631,7 @@ func (s *splashCmp) mcpBlock() string { ), ) } + +func (s *splashCmp) IsShowingAPIKey() bool { + return s.needsAPIKey +} diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/core.go similarity index 88% rename from internal/tui/components/core/helpers.go rename to internal/tui/components/core/core.go index 659ffd88c6b72b60933f9a19e3712093376a29bf..f5605db3927a9c1e55bd5706e2059c1ba9006106 100644 --- a/internal/tui/components/core/helpers.go +++ b/internal/tui/components/core/core.go @@ -5,12 +5,40 @@ import ( "strings" "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/crush/internal/tui/exp/diffview" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) +type KeyMapHelp interface { + Help() help.KeyMap +} + +type simpleHelp struct { + shortList []key.Binding + fullList [][]key.Binding +} + +func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap { + return &simpleHelp{ + shortList: shortList, + fullList: fullList, + } +} + +// FullHelp implements help.KeyMap. +func (s *simpleHelp) FullHelp() [][]key.Binding { + return s.fullList +} + +// ShortHelp implements help.KeyMap. +func (s *simpleHelp) ShortHelp() []key.Binding { + return s.shortList +} + func Section(text string, width int) string { t := styles.CurrentTheme() char := "─" diff --git a/internal/tui/components/core/layout/container.go b/internal/tui/components/core/layout/container.go deleted file mode 100644 index 9940a320e8c3a2733c8a543e09d5c25b68a103d1..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/container.go +++ /dev/null @@ -1,263 +0,0 @@ -package layout - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" -) - -type Container interface { - util.Model - Sizeable - Help - Positional - Focusable -} -type container struct { - width int - height int - isFocused bool - - x, y int - - content util.Model - - // Style options - paddingTop int - paddingRight int - paddingBottom int - paddingLeft int - - borderTop bool - borderRight bool - borderBottom bool - borderLeft bool - borderStyle lipgloss.Border -} - -type ContainerOption func(*container) - -func NewContainer(content util.Model, options ...ContainerOption) Container { - c := &container{ - content: content, - borderStyle: lipgloss.NormalBorder(), - } - - for _, option := range options { - option(c) - } - - return c -} - -func (c *container) Init() tea.Cmd { - return c.content.Init() -} - -func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - if c.IsFocused() { - u, cmd := c.content.Update(msg) - c.content = u.(util.Model) - return c, cmd - } - return c, nil - default: - u, cmd := c.content.Update(msg) - c.content = u.(util.Model) - return c, cmd - } -} - -func (c *container) Cursor() *tea.Cursor { - if cursor, ok := c.content.(util.Cursor); ok { - return cursor.Cursor() - } - return nil -} - -func (c *container) View() string { - t := styles.CurrentTheme() - width := c.width - height := c.height - - style := t.S().Base - - // Apply border if any side is enabled - if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft { - // Adjust width and height for borders - if c.borderTop { - height-- - } - if c.borderBottom { - height-- - } - if c.borderLeft { - width-- - } - if c.borderRight { - width-- - } - style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft) - style = style.BorderBackground(t.BgBase).BorderForeground(t.Border) - } - style = style. - Width(width). - Height(height). - PaddingTop(c.paddingTop). - PaddingRight(c.paddingRight). - PaddingBottom(c.paddingBottom). - PaddingLeft(c.paddingLeft) - - contentView := c.content.View() - return style.Render(contentView) -} - -func (c *container) SetSize(width, height int) tea.Cmd { - c.width = width - c.height = height - - // If the content implements Sizeable, adjust its size to account for padding and borders - if sizeable, ok := c.content.(Sizeable); ok { - // Calculate horizontal space taken by padding and borders - horizontalSpace := c.paddingLeft + c.paddingRight - if c.borderLeft { - horizontalSpace++ - } - if c.borderRight { - horizontalSpace++ - } - - // Calculate vertical space taken by padding and borders - verticalSpace := c.paddingTop + c.paddingBottom - if c.borderTop { - verticalSpace++ - } - if c.borderBottom { - verticalSpace++ - } - - // Set content size with adjusted dimensions - contentWidth := max(0, width-horizontalSpace) - contentHeight := max(0, height-verticalSpace) - return sizeable.SetSize(contentWidth, contentHeight) - } - return nil -} - -func (c *container) GetSize() (int, int) { - return c.width, c.height -} - -func (c *container) SetPosition(x, y int) tea.Cmd { - c.x = x - c.y = y - if positionable, ok := c.content.(Positional); ok { - return positionable.SetPosition(x, y) - } - return nil -} - -func (c *container) Bindings() []key.Binding { - if b, ok := c.content.(Help); ok { - return b.Bindings() - } - return nil -} - -// Blur implements Container. -func (c *container) Blur() tea.Cmd { - c.isFocused = false - if focusable, ok := c.content.(Focusable); ok { - return focusable.Blur() - } - return nil -} - -// Focus implements Container. -func (c *container) Focus() tea.Cmd { - c.isFocused = true - if focusable, ok := c.content.(Focusable); ok { - return focusable.Focus() - } - return nil -} - -// IsFocused implements Container. -func (c *container) IsFocused() bool { - isFocused := c.isFocused - if focusable, ok := c.content.(Focusable); ok { - isFocused = isFocused || focusable.IsFocused() - } - return isFocused -} - -// Padding options -func WithPadding(top, right, bottom, left int) ContainerOption { - return func(c *container) { - c.paddingTop = top - c.paddingRight = right - c.paddingBottom = bottom - c.paddingLeft = left - } -} - -func WithPaddingAll(padding int) ContainerOption { - return WithPadding(padding, padding, padding, padding) -} - -func WithPaddingHorizontal(padding int) ContainerOption { - return func(c *container) { - c.paddingLeft = padding - c.paddingRight = padding - } -} - -func WithPaddingVertical(padding int) ContainerOption { - return func(c *container) { - c.paddingTop = padding - c.paddingBottom = padding - } -} - -func WithBorder(top, right, bottom, left bool) ContainerOption { - return func(c *container) { - c.borderTop = top - c.borderRight = right - c.borderBottom = bottom - c.borderLeft = left - } -} - -func WithBorderAll() ContainerOption { - return WithBorder(true, true, true, true) -} - -func WithBorderHorizontal() ContainerOption { - return WithBorder(true, false, true, false) -} - -func WithBorderVertical() ContainerOption { - return WithBorder(false, true, false, true) -} - -func WithBorderStyle(style lipgloss.Border) ContainerOption { - return func(c *container) { - c.borderStyle = style - } -} - -func WithRoundedBorder() ContainerOption { - return WithBorderStyle(lipgloss.RoundedBorder()) -} - -func WithThickBorder() ContainerOption { - return WithBorderStyle(lipgloss.ThickBorder()) -} - -func WithDoubleBorder() ContainerOption { - return WithBorderStyle(lipgloss.DoubleBorder()) -} diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go index f5f2361d72d0d41bcb898c81f00df174571cfa72..6ceb30adf45595f5d44d4b4b48d6ac0feb87a028 100644 --- a/internal/tui/components/core/layout/layout.go +++ b/internal/tui/components/core/layout/layout.go @@ -5,6 +5,8 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" ) +// TODO: move this to core + type Focusable interface { Focus() tea.Cmd Blur() tea.Cmd @@ -23,8 +25,3 @@ type Help interface { type Positional interface { SetPosition(x, y int) tea.Cmd } - -// KeyMapProvider defines an interface for types that can provide their key bindings as a slice -type KeyMapProvider interface { - KeyBindings() []key.Binding -} diff --git a/internal/tui/components/core/layout/split.go b/internal/tui/components/core/layout/split.go deleted file mode 100644 index 16fdb6d78466e4818cbf9698fecd0860908f92f6..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/split.go +++ /dev/null @@ -1,375 +0,0 @@ -package layout - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" -) - -type LayoutPanel string - -const ( - LeftPanel LayoutPanel = "left" - RightPanel LayoutPanel = "right" - BottomPanel LayoutPanel = "bottom" -) - -type SplitPaneLayout interface { - util.Model - Sizeable - Help - SetLeftPanel(panel Container) tea.Cmd - SetRightPanel(panel Container) tea.Cmd - SetBottomPanel(panel Container) tea.Cmd - - ClearLeftPanel() tea.Cmd - ClearRightPanel() tea.Cmd - ClearBottomPanel() tea.Cmd - - FocusPanel(panel LayoutPanel) tea.Cmd - SetOffset(x, y int) -} - -type splitPaneLayout struct { - width int - height int - xOffset int - yOffset int - - ratio float64 - verticalRatio float64 - - rightPanel Container - leftPanel Container - bottomPanel Container - - fixedBottomHeight int // Fixed height for the bottom panel, if any - fixedRightWidth int // Fixed width for the right panel, if any -} - -type SplitPaneOption func(*splitPaneLayout) - -func (s *splitPaneLayout) Init() tea.Cmd { - var cmds []tea.Cmd - - if s.leftPanel != nil { - cmds = append(cmds, s.leftPanel.Init()) - } - - if s.rightPanel != nil { - cmds = append(cmds, s.rightPanel.Init()) - } - - if s.bottomPanel != nil { - cmds = append(cmds, s.bottomPanel.Init()) - } - - return tea.Batch(cmds...) -} - -func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return s, s.SetSize(msg.Width, msg.Height) - } - - if s.rightPanel != nil { - u, cmd := s.rightPanel.Update(msg) - s.rightPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - if s.leftPanel != nil { - u, cmd := s.leftPanel.Update(msg) - s.leftPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - if s.bottomPanel != nil { - u, cmd := s.bottomPanel.Update(msg) - s.bottomPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - return s, tea.Batch(cmds...) -} - -func (s *splitPaneLayout) Cursor() *tea.Cursor { - if s.bottomPanel != nil { - if c, ok := s.bottomPanel.(util.Cursor); ok { - return c.Cursor() - } - } else if s.rightPanel != nil { - if c, ok := s.rightPanel.(util.Cursor); ok { - return c.Cursor() - } - } else if s.leftPanel != nil { - if c, ok := s.leftPanel.(util.Cursor); ok { - return c.Cursor() - } - } - return nil -} - -func (s *splitPaneLayout) View() string { - var topSection string - - if s.leftPanel != nil && s.rightPanel != nil { - leftView := s.leftPanel.View() - rightView := s.rightPanel.View() - topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView) - } else if s.leftPanel != nil { - topSection = s.leftPanel.View() - } else if s.rightPanel != nil { - topSection = s.rightPanel.View() - } else { - topSection = "" - } - - var finalView string - - if s.bottomPanel != nil && topSection != "" { - bottomView := s.bottomPanel.View() - finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView) - } else if s.bottomPanel != nil { - finalView = s.bottomPanel.View() - } else { - finalView = topSection - } - - t := styles.CurrentTheme() - - style := t.S().Base. - Width(s.width). - Height(s.height) - - return style.Render(finalView) -} - -func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { - s.width = width - s.height = height - var topHeight, bottomHeight int - var cmds []tea.Cmd - if s.bottomPanel != nil { - if s.fixedBottomHeight > 0 { - bottomHeight = s.fixedBottomHeight - topHeight = height - bottomHeight - } else { - topHeight = int(float64(height) * s.verticalRatio) - bottomHeight = height - topHeight - if bottomHeight <= 0 { - bottomHeight = 2 - topHeight = height - bottomHeight - } - } - } else { - topHeight = height - bottomHeight = 0 - } - - var leftWidth, rightWidth int - if s.leftPanel != nil && s.rightPanel != nil { - if s.fixedRightWidth > 0 { - rightWidth = s.fixedRightWidth - leftWidth = width - rightWidth - } else { - leftWidth = int(float64(width) * s.ratio) - rightWidth = width - leftWidth - if rightWidth <= 0 { - rightWidth = 2 - leftWidth = width - rightWidth - } - } - } else if s.leftPanel != nil { - leftWidth = width - rightWidth = 0 - } else if s.rightPanel != nil { - leftWidth = 0 - rightWidth = width - } - - if s.leftPanel != nil { - cmd := s.leftPanel.SetSize(leftWidth, topHeight) - cmds = append(cmds, cmd) - if positional, ok := s.leftPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset)) - } - } - - if s.rightPanel != nil { - cmd := s.rightPanel.SetSize(rightWidth, topHeight) - cmds = append(cmds, cmd) - if positional, ok := s.rightPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset)) - } - } - - if s.bottomPanel != nil { - cmd := s.bottomPanel.SetSize(width, bottomHeight) - cmds = append(cmds, cmd) - if positional, ok := s.bottomPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight)) - } - } - return tea.Batch(cmds...) -} - -func (s *splitPaneLayout) GetSize() (int, int) { - return s.width, s.height -} - -func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd { - s.leftPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd { - s.rightPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd { - s.bottomPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd { - s.leftPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearRightPanel() tea.Cmd { - s.rightPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd { - s.bottomPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) Bindings() []key.Binding { - if s.leftPanel != nil { - if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() { - return b.Bindings() - } - } - if s.rightPanel != nil { - if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() { - return b.Bindings() - } - } - if s.bottomPanel != nil { - if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() { - return b.Bindings() - } - } - return nil -} - -func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd { - panels := map[LayoutPanel]Container{ - LeftPanel: s.leftPanel, - RightPanel: s.rightPanel, - BottomPanel: s.bottomPanel, - } - var cmds []tea.Cmd - for p, container := range panels { - if container == nil { - continue - } - if p == panel { - cmds = append(cmds, container.Focus()) - } else { - cmds = append(cmds, container.Blur()) - } - } - return tea.Batch(cmds...) -} - -// SetOffset implements SplitPaneLayout. -func (s *splitPaneLayout) SetOffset(x int, y int) { - s.xOffset = x - s.yOffset = y -} - -func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout { - layout := &splitPaneLayout{ - ratio: 0.8, - verticalRatio: 0.92, // Default 90% for top section, 10% for bottom - } - for _, option := range options { - option(layout) - } - return layout -} - -func WithLeftPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.leftPanel = panel - } -} - -func WithRightPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.rightPanel = panel - } -} - -func WithRatio(ratio float64) SplitPaneOption { - return func(s *splitPaneLayout) { - s.ratio = ratio - } -} - -func WithBottomPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.bottomPanel = panel - } -} - -func WithVerticalRatio(ratio float64) SplitPaneOption { - return func(s *splitPaneLayout) { - s.verticalRatio = ratio - } -} - -func WithFixedBottomHeight(height int) SplitPaneOption { - return func(s *splitPaneLayout) { - s.fixedBottomHeight = height - } -} - -func WithFixedRightWidth(width int) SplitPaneOption { - return func(s *splitPaneLayout) { - s.fixedRightWidth = width - } -} diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go index 3da14602ed2334f21e2af2e01574ccbcad0df8d5..fb0f461d810b74039ad466bfc5ade6e4be36d56f 100644 --- a/internal/tui/components/core/list/keys.go +++ b/internal/tui/components/core/list/keys.go @@ -9,6 +9,8 @@ type KeyMap struct { Up, DownOneItem, UpOneItem, + PageDown, + PageUp, HalfPageDown, HalfPageUp, Home, @@ -37,7 +39,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("d"), key.WithHelp("d", "half page down"), ), - HalfPageUp: key.NewBinding( + PageDown: key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), HalfPageUp: key.NewBinding( key.WithKeys("u"), key.WithHelp("u", "half page up"), ), diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index abbad6142506894f97fc275582697cee2d1b28c8..3f99eda5d979e72f0497a120e056df10aca228c3 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -332,6 +332,10 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.scrollDown(m.listHeight() / 2) case key.Matches(msg, m.keyMap.HalfPageUp): m.scrollUp(m.listHeight() / 2) + case key.Matches(msg, m.keyMap.PageDown): + m.scrollDown(m.listHeight()) + case key.Matches(msg, m.keyMap.PageUp): + m.scrollUp(m.listHeight()) case key.Matches(msg, m.keyMap.Home): return m, m.goToTop() case key.Matches(msg, m.keyMap.End): diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index bd87b013e86780d6a52a17f648d5f7479c7d350a..b7339705649f24129dc61c28471f23044ba7dafb 100644 --- a/internal/tui/components/core/status/status.go +++ b/internal/tui/components/core/status/status.go @@ -98,13 +98,12 @@ func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) { m.keyMap = keyMap } -func NewStatusCmp(keyMap help.KeyMap) StatusCmp { +func NewStatusCmp() StatusCmp { t := styles.CurrentTheme() help := help.New() help.Styles = t.S().Help return &statusCmp{ messageTTL: 5 * time.Second, help: help, - keyMap: keyMap, } } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 22c029dd355845b3ac7e2066a8a93bfb335c1d53..d055870e5ab24816fa002d2ad4f5fc171876d56e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -20,8 +20,8 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+c", "quit"), ), Help: key.NewBinding( - key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"), - key.WithHelp("ctrl+?", "more"), + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "more"), ), Commands: key.NewBinding( key.WithKeys("ctrl+p"), @@ -33,62 +33,3 @@ func DefaultKeyMap() KeyMap { ), } } - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := []key.Binding{ - k.Commands, - k.Sessions, - k.Quit, - k.Help, - } - slice = k.prependEscAndTab(slice) - slice = append(slice, k.pageBindings...) - // remove duplicates - seen := make(map[string]bool) - cleaned := []key.Binding{} - for _, b := range slice { - if !seen[b.Help().Key] { - seen[b.Help().Key] = true - cleaned = append(cleaned, b) - } - } - - for i := 0; i < len(cleaned); i += 3 { - end := min(i+3, len(cleaned)) - m = append(m, cleaned[i:end]) - } - return m -} - -func (k KeyMap) prependEscAndTab(bindings []key.Binding) []key.Binding { - var cancel key.Binding - var tab key.Binding - for _, b := range k.pageBindings { - if b.Help().Key == "esc" { - cancel = b - } - if b.Help().Key == "tab" { - tab = b - } - } - if tab.Help().Key != "" { - bindings = append([]key.Binding{tab}, bindings...) - } - if cancel.Help().Key != "" { - bindings = append([]key.Binding{cancel}, bindings...) - } - return bindings -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - bindings := []key.Binding{ - k.Commands, - k.Sessions, - k.Quit, - k.Help, - } - return k.prependEscAndTab(bindings) -} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 5cf5618b6705a87a41661bfe1d8d9b5d481da951..fe6127048132def361a8011fd2f51c4a6b0e5c09 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -2,8 +2,10 @@ package chat import ( "context" + "runtime" "time" + "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" @@ -20,6 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar" "github.com/charmbracelet/crush/internal/tui/components/chat/splash" "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" @@ -82,6 +85,7 @@ type chatPage struct { width, height int detailsWidth, detailsHeight int app *app.App + keyboardEnhancements tea.KeyboardEnhancementsMsg // Layout state compact bool @@ -103,6 +107,8 @@ type chatPage struct { showingDetails bool isCanceling bool splashFullScreen bool + isOnboarding bool + isProjectInit bool } func New(app *app.App) ChatPage { @@ -129,10 +135,12 @@ func (p *chatPage) Init() tea.Cmd { if !config.HasInitialDataConfig() { // First-time setup: show model selection p.splash.SetOnboarding(true) + p.isOnboarding = true p.splashFullScreen = true } else if b, _ := config.ProjectNeedsInitialization(); b { // Project needs CRUSH.md initialization p.splash.SetProjectInit(true) + p.isProjectInit = true p.splashFullScreen = true } else { // Ready to chat: focus editor, splash in background @@ -153,9 +161,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyboardEnhancementsMsg: - m, cmd := p.editor.Update(msg) - p.editor = m.(editor.Editor) - return p, cmd + p.keyboardEnhancements = msg + return p, nil case tea.WindowSizeMsg: return p, p.SetSize(msg.Width, msg.Height) case CancelTimerExpiredMsg: @@ -237,6 +244,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return p, util.ReportError(err) } + p.isOnboarding = false + p.isProjectInit = false p.focusedPane = PanelTypeEditor return p, p.SetSize(p.width, p.height) case tea.KeyPressMsg: @@ -579,6 +588,231 @@ func (p *chatPage) Bindings() []key.Binding { return bindings } +func (a *chatPage) Help() help.KeyMap { + var shortList []key.Binding + var fullList [][]key.Binding + switch { + case a.isOnboarding && !a.splash.IsShowingAPIKey(): + shortList = append(shortList, + // Choose model + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ), + // Accept selection + key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "accept"), + ), + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + case a.isOnboarding && a.splash.IsShowingAPIKey(): + var pasteKey key.Binding + if runtime.GOOS != "darwin" { + pasteKey = key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste API key"), + ) + } else { + pasteKey = key.NewBinding( + key.WithKeys("cmd+v"), + key.WithHelp("cmd+v", "paste API key"), + ) + } + shortList = append(shortList, + // Go back + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + // Paste + pasteKey, + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + case a.isProjectInit: + shortList = append(shortList, + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + default: + if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() { + cancelBinding := key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ) + if a.isCanceling { + cancelBinding = key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "press again to cancel"), + ) + } + shortList = append(shortList, cancelBinding) + fullList = append(fullList, + []key.Binding{ + cancelBinding, + }, + ) + } + globalBindings := []key.Binding{} + // we are in a session + if a.session.ID != "" { + tabKey := key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "focus chat"), + ) + if a.focusedPane == PanelTypeChat { + tabKey = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "focus editor"), + ) + } + shortList = append(shortList, tabKey) + globalBindings = append(globalBindings, tabKey) + } + commandsBinding := key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "commands"), + ) + helpBinding := key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "more"), + ) + globalBindings = append(globalBindings, commandsBinding) + globalBindings = append(globalBindings, + key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), + ) + if a.session.ID != "" { + globalBindings = append(globalBindings, + key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new sessions"), + )) + } + shortList = append(shortList, + // Commands + commandsBinding, + ) + fullList = append(fullList, globalBindings) + + if a.focusedPane == PanelTypeChat { + shortList = append(shortList, + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ), + ) + fullList = append(fullList, + []key.Binding{ + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ), + key.NewBinding( + key.WithKeys("shift+up", "shift+down"), + key.WithHelp("shift+↑↓", "next/prev item"), + ), + key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), + key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + }, + []key.Binding{ + key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ), + key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ), + key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "hone"), + ), + key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ), + }, + ) + } else if a.focusedPane == PanelTypeEditor { + newLineBinding := key.NewBinding( + key.WithKeys("shift+enter", "ctrl+j"), + // "ctrl+j" is a common keybinding for newline in many editors. If + // the terminal supports "shift+enter", we substitute the help text + // to reflect that. + key.WithHelp("ctrl+j", "newline"), + ) + if a.keyboardEnhancements.SupportsKeyDisambiguation() { + newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc) + } + shortList = append(shortList, newLineBinding) + fullList = append(fullList, + []key.Binding{ + newLineBinding, + key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add image"), + ), + key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "add file"), + ), + key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "open editor"), + ), + }) + } + shortList = append(shortList, + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + // Help + helpBinding, + ) + fullList = append(fullList, []key.Binding{ + key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "less"), + ), + }) + } + + return core.NewSimpleHelp(shortList, fullList) +} + func (p *chatPage) IsChatFocused() bool { return p.focusedPane == PanelTypeChat } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f8b30c4a5f761c40bffb07667b92fc89bf631e04..c93d24dae4d16e8d132248359d43657b98a352c8 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/pubsub" cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/core/status" "github.com/charmbracelet/crush/internal/tui/components/dialogs" @@ -257,7 +258,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd { var cmds []tea.Cmd a.wWidth, a.wHeight = width, height if a.showingFullHelp { - height -= 4 + height -= 5 } else { height -= 2 } @@ -384,10 +385,9 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { // View renders the complete application interface including pages, dialogs, and overlays. func (a *appModel) View() tea.View { page := a.pages[a.currentPage] - if withHelp, ok := page.(layout.Help); ok { - a.keyMap.pageBindings = withHelp.Bindings() + if withHelp, ok := page.(core.KeyMapHelp); ok { + a.status.SetKeyMap(withHelp.Help()) } - a.status.SetKeyMap(a.keyMap) pageView := page.View() components := []string{ pageView, @@ -447,7 +447,7 @@ func New(app *app.App) tea.Model { model := &appModel{ currentPage: chat.ChatPageID, app: app, - status: status.NewStatusCmp(keyMap), + status: status.NewStatusCmp(), loadedPages: make(map[page.PageID]bool), keyMap: keyMap,