chore: improve help

Kujtim Hoxha created

Change summary

internal/tui/components/chat/editor/editor.go    |   3 
internal/tui/components/chat/editor/keys.go      |   9 
internal/tui/components/chat/splash/splash.go    |  13 
internal/tui/components/core/core.go             |  28 +
internal/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(-)

Detailed changes

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))

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,
 	}
 }
 

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
+}

internal/tui/components/core/helpers.go → 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 := "─"

internal/tui/components/core/layout/container.go 🔗

@@ -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())
-}

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
-}

internal/tui/components/core/layout/split.go 🔗

@@ -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
-	}
-}

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"),
 		),

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):

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,
 	}
 }

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)
-}

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
 }

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,