Detailed changes
  
  
    
    @@ -5,7 +5,7 @@ go 1.17
 require (
 	github.com/alecthomas/chroma v0.10.0
 	github.com/caarlos0/env/v6 v6.9.1
-	github.com/charmbracelet/bubbles v0.10.4-0.20220302223835-88562515cf7b
+	github.com/charmbracelet/bubbles v0.10.4-0.20220412141214-292a1dd7ba97
 	github.com/charmbracelet/bubbletea v0.20.0
 	github.com/charmbracelet/glamour v0.4.0
 	github.com/charmbracelet/lipgloss v0.4.0
  
  
  
    
    @@ -23,8 +23,12 @@ github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k
 github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
 github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3 h1:w2ANoiT4ubmh4Nssa3/QW1M7lj3FZkma8f8V5aBDxXM=
 github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
+github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
+github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
 github.com/charmbracelet/bubbles v0.10.4-0.20220302223835-88562515cf7b h1:o+LFpRn1fXtu1hDJLtBFjp7tMZ8AqwSpl84w1TnUj0Y=
 github.com/charmbracelet/bubbles v0.10.4-0.20220302223835-88562515cf7b/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
+github.com/charmbracelet/bubbles v0.10.4-0.20220412141214-292a1dd7ba97 h1:NJqAUfS+JNHqodsbhLR0zD3sDkXI7skwjAwd77HXe/Q=
+github.com/charmbracelet/bubbles v0.10.4-0.20220412141214-292a1dd7ba97/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
 github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
 github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc=
 github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM=
  
  
  
    
    @@ -50,7 +50,7 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
 		if ac.Cfg.Callbacks != nil {
 			ac.Cfg.Callbacks.Tui("new session")
 		}
-		c := &common.Common{
+		c := common.Common{
 			Styles: styles.DefaultStyles(),
 			Keymap: keymap.DefaultKeyMap(),
 			Width:  pty.Window.Width,
  
  
  
    
    @@ -11,3 +11,8 @@ type Common struct {
 	Width  int
 	Height int
 }
+
+func (c Common) SetSize(width, height int) {
+	c.Width = width
+	c.Height = height
+}
  
  
  
    
    @@ -0,0 +1,16 @@
+package common
+
+import (
+	"github.com/charmbracelet/bubbles/help"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+type Component interface {
+	tea.Model
+	SetSize(width, height int)
+}
+
+type Page interface {
+	Component
+	help.KeyMap
+}
  
  
  
    
    @@ -0,0 +1,46 @@
+package footer
+
+import (
+	"github.com/charmbracelet/bubbles/help"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+type Footer struct {
+	common common.Common
+	help   help.Model
+	keymap help.KeyMap
+}
+
+func New(c common.Common, keymap help.KeyMap) *Footer {
+	h := help.New()
+	h.Styles.ShortKey = c.Styles.HelpKey
+	h.Styles.FullKey = c.Styles.HelpKey
+	f := &Footer{
+		common: c,
+		help:   h,
+		keymap: keymap,
+	}
+	return f
+}
+
+func (f *Footer) SetSize(width, height int) {
+	f.common.Width = width
+	f.common.Height = height
+}
+
+func (f *Footer) Init() tea.Cmd {
+	return nil
+}
+
+func (f *Footer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return f, nil
+}
+
+func (f *Footer) View() string {
+	if f.keymap == nil {
+		return ""
+	}
+	s := f.common.Styles.Footer.Copy().Width(f.common.Width)
+	return s.Render(f.help.View(f.keymap))
+}
  
  
  
    
    @@ -0,0 +1,39 @@
+package header
+
+import (
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+type Header struct {
+	common common.Common
+	text   string
+}
+
+func New(c common.Common, text string) *Header {
+	h := &Header{
+		common: c,
+		text:   text,
+	}
+	return h
+}
+
+func (h *Header) SetSize(width, height int) {
+	h.common.Width = width
+	h.common.Height = height
+}
+
+func (h *Header) Init() tea.Cmd {
+	return nil
+}
+
+func (h *Header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return h, nil
+}
+
+func (h *Header) View() string {
+	s := h.common.Styles.Header.Copy().Width(h.common.Width)
+	return s.Render(strings.TrimSpace(h.text))
+}
  
  
  
    
    @@ -8,6 +8,7 @@ import (
 
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/ui/components/yankable"
 	"github.com/charmbracelet/soft-serve/ui/styles"
 	"github.com/dustin/go-humanize"
@@ -80,13 +81,23 @@ func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 	i := listItem.(Item)
 	s := strings.Builder{}
-	style := d.styles.MenuItem
+	style := d.styles.MenuItem.Copy()
 	if index == m.Index() {
-		style = d.styles.SelectedMenuItem
+		style = d.styles.SelectedMenuItem.Copy()
 	}
-	updated := d.styles.MenuLastUpdate.Render(fmt.Sprintf("Updated %s", humanize.Time(i.LastUpdate)))
+	style.Width(m.Width() - 2) // FIXME figure out where this "2" comes from
+	titleStr := i.Title
+	updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.LastUpdate))
+	updated := d.styles.MenuLastUpdate.
+		Copy().
+		Width(m.Width() - style.GetHorizontalFrameSize() - lipgloss.Width(titleStr)).
+		Render(updatedStr)
+	title := lipgloss.NewStyle().
+		Align(lipgloss.Left).
+		Width(m.Width() - style.GetHorizontalFrameSize() - lipgloss.Width(updated)).
+		Render(titleStr)
 
-	s.WriteString(fmt.Sprintf("%s %s", i.Title, updated))
+	s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated))
 	s.WriteString("\n")
 	s.WriteString(i.Description)
 	s.WriteString("\n\n")
  
  
  
    
    @@ -8,10 +8,10 @@ import (
 
 type Selector struct {
 	list   list.Model
-	common *common.Common
+	common common.Common
 }
 
-func New(common *common.Common, items []list.Item) *Selector {
+func New(common common.Common, items []list.Item) *Selector {
 	l := list.New(items, ItemDelegate{common.Styles}, common.Width, common.Height)
 	l.SetShowTitle(false)
 	l.SetShowHelp(false)
@@ -21,10 +21,16 @@ func New(common *common.Common, items []list.Item) *Selector {
 		list:   l,
 		common: common,
 	}
+	s.SetSize(common.Width, common.Height)
 	return s
 }
 
+func (s *Selector) KeyMap() list.KeyMap {
+	return s.list.KeyMap
+}
+
 func (s *Selector) SetSize(width, height int) {
+	s.common.SetSize(width, height)
 	s.list.SetSize(width, height)
 }
 
  
  
  
    
    @@ -4,6 +4,7 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
@@ -16,11 +17,11 @@ import (
 
 type Selection struct {
 	s        session.Session
-	common   *common.Common
+	common   common.Common
 	selector *selector.Selector
 }
 
-func New(s session.Session, common *common.Common) *Selection {
+func New(s session.Session, common common.Common) *Selection {
 	sel := &Selection{
 		s:        s,
 		common:   common,
@@ -29,6 +30,46 @@ func New(s session.Session, common *common.Common) *Selection {
 	return sel
 }
 
+func (s *Selection) SetSize(width, height int) {
+	s.common.SetSize(width, height)
+	s.selector.SetSize(width, height)
+}
+
+func (s *Selection) ShortHelp() []key.Binding {
+	k := s.selector.KeyMap()
+	return []key.Binding{
+		s.common.Keymap.UpDown,
+		s.common.Keymap.Select,
+		k.Filter,
+		k.ClearFilter,
+	}
+}
+
+func (s *Selection) FullHelp() [][]key.Binding {
+	k := s.selector.KeyMap()
+	return [][]key.Binding{
+		{
+			k.CursorUp,
+			k.CursorDown,
+			k.NextPage,
+			k.PrevPage,
+			k.GoToStart,
+			k.GoToEnd,
+		},
+		{
+			k.Filter,
+			k.ClearFilter,
+			k.CancelWhileFiltering,
+			k.AcceptWhileFiltering,
+			k.ShowFullHelp,
+			k.CloseFullHelp,
+		},
+		// Ignore the following keys:
+		// k.Quit,
+		// k.ForceQuit,
+	}
+}
+
 func (s *Selection) Init() tea.Cmd {
 	items := make([]list.Item, 0)
 	cfg := s.s.Config()
  
  
  
    
    @@ -87,8 +87,9 @@ func DefaultStyles() *Styles {
 		Margin(1, 2)
 
 	s.Header = lipgloss.NewStyle().
-		Foreground(lipgloss.Color("62")).
+		Foreground(lipgloss.Color("15")).
 		Align(lipgloss.Right).
+		Height(1).
 		Bold(true)
 
 	s.Menu = lipgloss.NewStyle().
@@ -109,7 +110,8 @@ func DefaultStyles() *Styles {
 		BorderForeground(lipgloss.Color("241"))
 
 	s.MenuLastUpdate = lipgloss.NewStyle().
-		Foreground(lipgloss.Color("241"))
+		Foreground(lipgloss.Color("241")).
+		Align(lipgloss.Right)
 
 	s.SelectedMenuItem = s.MenuItem.Copy().
 		BorderForeground(s.ActiveBorderColor)
@@ -172,7 +174,7 @@ func DefaultStyles() *Styles {
 		PaddingRight(1)
 
 	s.Footer = lipgloss.NewStyle().
-		MarginTop(1)
+		Height(1)
 
 	s.Branch = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("203")).
  
  
  
    
    @@ -1,9 +1,14 @@
 package ui
 
 import (
+	"strings"
+
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/footer"
+	"github.com/charmbracelet/soft-serve/ui/components/header"
 	"github.com/charmbracelet/soft-serve/ui/pages/selection"
 	"github.com/charmbracelet/soft-serve/ui/session"
 )
@@ -18,72 +23,108 @@ const (
 
 type UI struct {
 	s          session.Session
-	common     *common.Common
-	pages      []tea.Model
+	common     common.Common
+	pages      []common.Page
 	activePage int
 	state      sessionState
+	header     *header.Header
+	footer     *footer.Footer
 }
 
-func New(s session.Session, common *common.Common, initialRepo string) *UI {
+func New(s session.Session, c common.Common, initialRepo string) *UI {
+	h := header.New(c, s.Config().Name)
 	ui := &UI{
 		s:          s,
-		common:     common,
-		pages:      make([]tea.Model, 2), // selection & repo
+		common:     c,
+		pages:      make([]common.Page, 2), // selection & repo
 		activePage: 0,
 		state:      startState,
+		header:     h,
 	}
+	ui.footer = footer.New(c, ui)
+	ui.SetSize(c.Width, c.Height)
 	return ui
 }
 
-func (ui *UI) Init() tea.Cmd {
-	items := make([]string, 0)
-	cfg := ui.s.Config()
-	for _, r := range cfg.Repos {
-		items = append(items, r.Name)
-	}
-	for _, r := range cfg.Source.AllRepos() {
-		exists := false
-		for _, i := range items {
-			if i == r.Name() {
-				exists = true
-				break
-			}
-		}
-		if !exists {
-			items = append(items, r.Name())
+func (ui *UI) getMargins() (wm, hm int) {
+	wm = ui.common.Styles.App.GetHorizontalFrameSize()
+	hm = ui.common.Styles.App.GetVerticalFrameSize() +
+		ui.common.Styles.Header.GetHeight() +
+		ui.common.Styles.Footer.GetHeight()
+	return
+}
+
+func (ui *UI) ShortHelp() []key.Binding {
+	b := make([]key.Binding, 0)
+	b = append(b, ui.pages[ui.activePage].ShortHelp()...)
+	b = append(b, ui.common.Keymap.Quit)
+	return b
+}
+
+func (ui *UI) FullHelp() [][]key.Binding {
+	b := make([][]key.Binding, 0)
+	b = append(b, ui.pages[ui.activePage].FullHelp()...)
+	b = append(b, []key.Binding{ui.common.Keymap.Quit})
+	return b
+}
+
+func (ui *UI) SetSize(width, height int) {
+	ui.common.SetSize(width, height)
+	wm, hm := ui.getMargins()
+	ui.header.SetSize(width-wm, height-hm)
+	ui.footer.SetSize(width-wm, height-hm)
+	for _, p := range ui.pages {
+		if p != nil {
+			p.SetSize(width-wm, height-hm)
 		}
 	}
+}
+
+func (ui *UI) Init() tea.Cmd {
 	ui.pages[0] = selection.New(ui.s, ui.common)
 	ui.pages[1] = selection.New(ui.s, ui.common)
+	ui.SetSize(ui.common.Width, ui.common.Height)
 	ui.state = loadedState
 	return ui.pages[ui.activePage].Init()
 }
 
+// TODO update help when page change.
 func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
+		h, cmd := ui.header.Update(msg)
+		ui.header = h.(*header.Header)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		f, cmd := ui.footer.Update(msg)
+		ui.footer = f.(*footer.Footer)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 		for i, p := range ui.pages {
 			m, cmd := p.Update(msg)
-			ui.pages[i] = m
+			ui.pages[i] = m.(common.Page)
 			if cmd != nil {
 				cmds = append(cmds, cmd)
 			}
 		}
+		ui.SetSize(msg.Width, msg.Height)
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, ui.common.Keymap.Quit):
 			return ui, tea.Quit
 		default:
 			m, cmd := ui.pages[ui.activePage].Update(msg)
-			ui.pages[ui.activePage] = m
+			ui.pages[ui.activePage] = m.(common.Page)
 			if cmd != nil {
 				cmds = append(cmds, cmd)
 			}
 		}
 	default:
 		m, cmd := ui.pages[ui.activePage].Update(msg)
-		ui.pages[ui.activePage] = m
+		ui.pages[ui.activePage] = m.(common.Page)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
@@ -92,14 +133,21 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (ui *UI) View() string {
+	s := strings.Builder{}
 	switch ui.state {
 	case startState:
 		return "Loading..."
 	case errorState:
 		return "Error"
 	case loadedState:
-		return ui.common.Styles.App.Render(ui.pages[ui.activePage].View())
+		s.WriteString(lipgloss.JoinVertical(
+			lipgloss.Bottom,
+			ui.header.View(),
+			ui.pages[ui.activePage].View(),
+			ui.footer.View(),
+		))
 	default:
 		return "Unknown state :/ this is a bug!"
 	}
+	return ui.common.Styles.App.Render(s.String())
 }