1476c3050dd6ebe9f26d673c6d70daa4e4b41d4f

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

feat(ui): new tui

wip: new ui

wip: selector

wip: header & footer

wip: selection readme

wip: add readme

wip: copy text

wip: fix readme wrap

wip: fix errorview margin

wip: underline filtered items

wip: add selector mouse wheel support

wip: fix reset formatting on line wraps

wip: initial repo ui implementation

wip: repo header

go mod tidy

add git branch symbol

fix: selector keymap

feat: display repo commits

feat: display commit diffs

godox

fix config tests

feat: render error in app style

feat: repo files tab

feat: add refs component

fix: glitches & add refs statusbar

feat: add full help toggle

fix: error view height

wip

init ui and check for errors

fix: initial repo

fix: url style

fix: selecting repo

feat: new log item style

feat: copy over ssh

clean

feat: detect private repos

feat: indicate private repos via emoji

feat: only show private repos for admins

feat: add files content line number

fix: bold selected file size

fix: remove header from repo page

fix: comitter & authored name highlight

fix: repo last updated time

feat: add statusbar symbols

fix: no repos

fix: prevent tab out of bound error

fix: decrease help columns

fix: crooked ui size rendering on startup

fix: various improvements

* don't add line numbers to markdown files
* fix selection active item styles
* fix selection readme styles
* add selection description styles

fix: add footer padding

fix: move repo readme into its own model

* fix refs switch flickering

fix: selection item truncate string

fix: footer padding

feat: repos list title & styles

fix: server cli interface

fix: simplify ui session struct

feat: selection tabs redesign

fix: no reference nil deref error

feat: add log commits loading

fix: replace tabs with space to avoid breaking files line wrapping

clean

fix(ui): selection box position when no repos

fix: log status bar after loading

fix: use actual repo name in status bar

fix(ui): styling after line breaks

fix(ui): subtle ui changes

* hide help from repo page
* file sizes now appear on the right side

fix(ui): show "no description" when a repo doesn't have one

fix(ui): glamour line wrapping

fix(ui): show footer on error

fix(ui): truncate repo clone cmd

fix(ui): truncate git clone command on repo page

fix(ui): truncate strings on small terminal width

fix(ui): switch to files tab on window resize

fix(examples): setuid imports

clean

fix: layout misc changes

fix: wrong dockerfile background highlight

fix(ui): styles on light terminal backgrounds

fix: don't use nerdfonts symbols

fix(git): cache head commits

fix(ui): loading commits spinner text

clear log selected commit after going back to commits log view

fix: respect private repos

Fixes: https://github.com/charmbracelet/soft-serve/issues/81

feat: update deps

Diff

This diff is truncated to protect this page.

   1diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go
   2index ecdd628744766a522b901747035c2913533858fc..6ceebbad6f6defdfa670493e6ed83351c1822f64 100644
   3--- a/cmd/soft/serve.go
   4+++ b/cmd/soft/serve.go
   5@@ -8,8 +8,8 @@ import (
   6 	"syscall"
   7 	"time"
   8 
   9-	"github.com/charmbracelet/soft-serve/config"
  10 	"github.com/charmbracelet/soft-serve/server"
  11+	"github.com/charmbracelet/soft-serve/server/config"
  12 	"github.com/spf13/cobra"
  13 )
  14 
  15diff --git a/internal/config/git.go b/config/auth.go
  16rename from internal/config/git.go
  17rename to config/auth.go
  18diff --git a/config/config.go b/config/config.go
  19index dc60e2dad1f2cd802a98d1ca3894c6db4bc0d8ba..d64fa9fddf8c7feb87841f3ced52e23e756cbfb7 100644
  20--- a/config/config.go
  21+++ b/config/config.go
  22@@ -1,53 +1,279 @@
  23 package config
  24 
  25 import (
  26+	"bytes"
  27+	"errors"
  28+	"io/fs"
  29 	"log"
  30 	"path/filepath"
  31+	"strings"
  32+	"sync"
  33+	"text/template"
  34+	"time"
  35 
  36-	"github.com/caarlos0/env/v6"
  37+	"golang.org/x/crypto/ssh"
  38+	"gopkg.in/yaml.v3"
  39+
  40+	"fmt"
  41+	"os"
  42+
  43+	"github.com/charmbracelet/soft-serve/server/config"
  44+	"github.com/go-git/go-billy/v5/memfs"
  45+	ggit "github.com/go-git/go-git/v5"
  46+	"github.com/go-git/go-git/v5/plumbing/object"
  47+	"github.com/go-git/go-git/v5/plumbing/transport"
  48+	"github.com/go-git/go-git/v5/storage/memory"
  49 )
  50 
  51-// Callbacks provides an interface that can be used to run callbacks on different events.
  52-type Callbacks interface {
  53-	Tui(action string)
  54-	Push(repo string)
  55-	Fetch(repo string)
  56+// Config is the Soft Serve configuration.
  57+type Config struct {
  58+	Name         string         `yaml:"name"`
  59+	Host         string         `yaml:"host"`
  60+	Port         int            `yaml:"port"`
  61+	AnonAccess   string         `yaml:"anon-access"`
  62+	AllowKeyless bool           `yaml:"allow-keyless"`
  63+	Users        []User         `yaml:"users"`
  64+	Repos        []MenuRepo     `yaml:"repos"`
  65+	Source       *RepoSource    `yaml:"-"`
  66+	Cfg          *config.Config `yaml:"-"`
  67+	mtx          sync.Mutex
  68 }
  69 
  70-// Config is the configuration for Soft Serve.
  71-type Config struct {
  72-	BindAddr         string   `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
  73-	Host             string   `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
  74-	Port             int      `env:"SOFT_SERVE_PORT" envDefault:"23231"`
  75-	KeyPath          string   `env:"SOFT_SERVE_KEY_PATH"`
  76-	RepoPath         string   `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
  77-	InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
  78-	Callbacks        Callbacks
  79-	ErrorLog         *log.Logger
  80+// User contains user-level configuration for a repository.
  81+type User struct {
  82+	Name        string   `yaml:"name"`
  83+	Admin       bool     `yaml:"admin"`
  84+	PublicKeys  []string `yaml:"public-keys"`
  85+	CollabRepos []string `yaml:"collab-repos"`
  86 }
  87 
  88-// DefaultConfig returns a Config with the values populated with the defaults
  89-// or specified environment variables.
  90-func DefaultConfig() *Config {
  91-	cfg := &Config{ErrorLog: log.Default()}
  92-	if err := env.Parse(cfg); err != nil {
  93-		log.Fatalln(err)
  94+// Repo contains repository configuration information.
  95+type MenuRepo struct {
  96+	Name    string `yaml:"name"`
  97+	Repo    string `yaml:"repo"`
  98+	Note    string `yaml:"note"`
  99+	Private bool   `yaml:"private"`
 100+	Readme  string `yaml:"readme"`
 101+}
 102+
 103+// NewConfig creates a new internal Config struct.
 104+func NewConfig(cfg *config.Config) (*Config, error) {
 105+	var anonAccess string
 106+	var yamlUsers string
 107+	var displayHost string
 108+	host := cfg.Host
 109+	port := cfg.Port
 110+
 111+	pks := make([]string, 0)
 112+	for _, k := range cfg.InitialAdminKeys {
 113+		if bts, err := os.ReadFile(k); err == nil {
 114+			// pk is a file, set its contents as pk
 115+			k = string(bts)
 116+		}
 117+		var pk = strings.TrimSpace(k)
 118+		if pk == "" {
 119+			continue
 120+		}
 121+		// it is a valid ssh key, nothing to do
 122diff --git a/config/config_test.go b/config/config_test.go
 123index 6a8eb7e4890f4ff535b7523491936a6273590755..12ddd8c244a7a556f1498dace6b7da61a1e5d706 100644
 124--- a/config/config_test.go
 125+++ b/config/config_test.go
 126@@ -1,19 +1,37 @@
 127 package config
 128 
 129 import (
 130-	"os"
 131 	"testing"
 132 
 133+	"github.com/charmbracelet/soft-serve/server/config"
 134 	"github.com/matryer/is"
 135 )
 136 
 137-func TestParseMultipleKeys(t *testing.T) {
 138+func TestMultipleInitialKeys(t *testing.T) {
 139+	cfg, err := NewConfig(&config.Config{
 140+		RepoPath: t.TempDir(),
 141+		KeyPath:  t.TempDir(),
 142+		InitialAdminKeys: []string{
 143+			"testdata/k1.pub",
 144+			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
 145+		},
 146+	})
 147 	is := is.New(t)
 148-	is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
 149-	t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
 150-	cfg := DefaultConfig()
 151-	is.Equal(cfg.InitialAdminKeys, []string{
 152-		"testdata/k1.pub",
 153-		"testdata/k2.pub",
 154+	is.NoErr(err)
 155+	err = cfg.Reload()
 156+	is.NoErr(err)
 157+	is.Equal(cfg.Users[0].PublicKeys, []string{
 158+		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
 159+		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
 160+	}) // should have both keys
 161+}
 162+
 163+func TestEmptyInitialKeys(t *testing.T) {
 164+	cfg, err := NewConfig(&config.Config{
 165+		RepoPath: t.TempDir(),
 166+		KeyPath:  t.TempDir(),
 167 	})
 168+	is := is.New(t)
 169+	is.NoErr(err)
 170+	is.Equal(len(cfg.Users), 0) // should not have any users
 171 }
 172diff --git a/internal/config/defaults.go b/config/defaults.go
 173rename from internal/config/defaults.go
 174rename to config/defaults.go
 175diff --git a/internal/git/git.go b/config/git.go
 176rename from internal/git/git.go
 177rename to config/git.go
 178index 89762d524c558aecdf42552c0e75fb20a0abb7a7..857a5898ed970bb586042225cd5c84c5686055dd 100644
 179--- a/internal/git/git.go
 180+++ b/config/git.go
 181@@ -1,4 +1,4 @@
 182-package git
 183+package config
 184 
 185 import (
 186 	"errors"
 187@@ -17,13 +17,17 @@ var ErrMissingRepo = errors.New("missing repo")
 188 
 189 // Repo represents a Git repository.
 190 type Repo struct {
 191-	path       string
 192-	repository *git.Repository
 193-	readme     string
 194-	readmePath string
 195-	head       *git.Reference
 196-	refs       []*git.Reference
 197-	patchCache *lru.Cache
 198+	name        string
 199+	description string
 200+	path        string
 201+	repository  *git.Repository
 202+	readme      string
 203+	readmePath  string
 204+	head        *git.Reference
 205+	headCommit  string
 206+	refs        []*git.Reference
 207+	patchCache  *lru.Cache
 208+	private     bool
 209 }
 210 
 211 // open opens a Git repository.
 212@@ -48,16 +52,34 @@ func (rs *RepoSource) open(path string) (*Repo, error) {
 213 	return r, nil
 214 }
 215 
 216+// IsPrivate returns true if the repository is private.
 217+func (r *Repo) IsPrivate() bool {
 218+	return r.private
 219+}
 220+
 221 // Path returns the path to the repository.
 222 func (r *Repo) Path() string {
 223 	return r.path
 224 }
 225 
 226-// GetName returns the name of the repository.
 227-func (r *Repo) Name() string {
 228+// Repo returns the repository directory name.
 229+func (r *Repo) Repo() string {
 230 	return filepath.Base(r.path)
 231 }
 232 
 233+// Name returns the name of the repository.
 234+func (r *Repo) Name() string {
 235+	if r.name == "" {
 236+		return r.Repo()
 237+	}
 238+	return r.name
 239+}
 240+
 241+// Description returns the description for a repository.
 242+func (r *Repo) Description() string {
 243+	return r.description
 244+}
 245+
 246 // Readme returns the readme and its path for the repository.
 247 func (r *Repo) Readme() (readme string, path string) {
 248 	return r.readme, r.readmePath
 249@@ -124,6 +146,22 @@ func (r *Repo) CountCommits(ref *git.Reference) (int64, error) {
 250 	return tc, nil
 251 }
 252 
 253+// Commit returns the commit for a given hash.
 254+func (r *Repo) Commit(hash string) (*git.Commit, error) {
 255+	if hash == "HEAD" && r.headCommit != "" {
 256+		hash = r.headCommit
 257+	}
 258+	c, err := r.repository.CatFileCommit(hash)
 259+	if err != nil {
 260+		return nil, err
 261+	}
 262+	r.headCommit = c.ID.String()
 263+	return &git.Commit{
 264+		Commit: c,
 265+		Hash:   git.Hash(c.ID.String()),
 266+	}, nil
 267+}
 268+
 269 // CommitsByPage returns the commits for a repository.
 270 func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) {
 271 	return r.repository.CommitsByPage(ref, page, size)
 272@@ -201,6 +239,7 @@ func (rs *RepoSource) LoadRepo(name string) error {
 273 	rp := filepath.Join(rs.Path, name)
 274 	r, err := rs.open(rp)
 275 	if err != nil {
 276+		log.Printf("error opening repository %s: %s", name, err)
 277 		return err
 278 	}
 279 	rs.repos[name] = r
 280diff --git a/internal/config/testdata/k1.pub b/config/testdata/k1.pub
 281rename from internal/config/testdata/k1.pub
 282rename to config/testdata/k1.pub
 283diff --git a/examples/setuid/main.go b/examples/setuid/main.go
 284index 4c05e5fb2e84d37eef637fd03625a78fb4c0ae50..0c58fbb8347581193de163564c52eaf50a5aff2a 100644
 285--- a/examples/setuid/main.go
 286+++ b/examples/setuid/main.go
 287@@ -18,8 +18,8 @@ import (
 288 	"syscall"
 289 	"time"
 290 
 291-	"github.com/charmbracelet/soft-serve/config"
 292 	"github.com/charmbracelet/soft-serve/server"
 293+	"github.com/charmbracelet/soft-serve/server/config"
 294 )
 295 
 296 var (
 297diff --git a/go.mod b/go.mod
 298index ffc7aed12ac21ebb49549ca399fd16bc2f1e7789..cafe191325967236e1c65a01c49a41399b04d5bf 100755
 299--- a/go.mod
 300+++ b/go.mod
 301@@ -6,8 +6,8 @@ require (
 302 	github.com/alecthomas/chroma v0.10.0
 303 	github.com/caarlos0/env/v6 v6.9.1
 304 	github.com/charmbracelet/bubbles v0.11.0
 305-	github.com/charmbracelet/bubbletea v0.22.0
 306-	github.com/charmbracelet/glamour v0.4.0
 307+	github.com/charmbracelet/bubbletea v0.21.0
 308+	github.com/charmbracelet/glamour v0.5.0
 309 	github.com/charmbracelet/lipgloss v0.5.0
 310 	github.com/charmbracelet/wish v0.5.0
 311 	github.com/dustin/go-humanize v1.0.0
 312@@ -22,6 +22,7 @@ require (
 313 )
 314 
 315 require (
 316+	github.com/aymanbagabas/go-osc52 v1.0.3
 317 	github.com/charmbracelet/keygen v0.3.0
 318 	github.com/gobwas/glob v0.2.3
 319 	github.com/gogs/git-module v1.6.1
 320@@ -68,6 +69,6 @@ require (
 321 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 322 	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
 323 	golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
 324-	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
 325+	golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
 326 	gopkg.in/warnings.v0 v0.1.2 // indirect
 327 )
 328diff --git a/go.sum b/go.sum
 329index 33dfda0d298ab4e4aecf23c4bf0868352a12a29f..b1d4a988d5ba46c69c78def1438ff64ea030728b 100644
 330--- a/go.sum
 331+++ b/go.sum
 332@@ -17,6 +17,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
 333 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 334 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 335 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 336+github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
 337+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 338 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 339 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 340 github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k=
 341@@ -27,11 +29,10 @@ github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQ
 342 github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q=
 343 github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
 344 github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM=
 345+github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
 346 github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
 347-github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
 348-github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
 349-github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k=
 350-github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
 351+github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
 352+github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
 353 github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
 354 github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=
 355 github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=
 356@@ -215,8 +216,9 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIj
 357 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 358 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 359 golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 360-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 361 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 362+golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
 363+golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 364 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 365 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 366 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 367diff --git a/internal/config/config.go b/internal/config/config.go
 368deleted file mode 100644
 369index 86cbc52b734dc1281c6ed0f6debc59f5523aa2cf..0000000000000000000000000000000000000000
 370--- a/internal/config/config.go
 371+++ /dev/null
 372@@ -1,277 +0,0 @@
 373-package config
 374-
 375-import (
 376-	"bytes"
 377-	"errors"
 378-	"io/fs"
 379-	"log"
 380-	"path/filepath"
 381-	"strings"
 382-	"sync"
 383-	"text/template"
 384-	"time"
 385-
 386-	"golang.org/x/crypto/ssh"
 387-	"gopkg.in/yaml.v3"
 388-
 389-	"fmt"
 390-	"os"
 391-
 392-	"github.com/charmbracelet/soft-serve/config"
 393-	"github.com/charmbracelet/soft-serve/internal/git"
 394-	"github.com/go-git/go-billy/v5/memfs"
 395-	ggit "github.com/go-git/go-git/v5"
 396-	"github.com/go-git/go-git/v5/plumbing/object"
 397-	"github.com/go-git/go-git/v5/plumbing/transport"
 398-	"github.com/go-git/go-git/v5/storage/memory"
 399-)
 400-
 401-// Config is the Soft Serve configuration.
 402-type Config struct {
 403-	Name         string          `yaml:"name"`
 404-	Host         string          `yaml:"host"`
 405-	Port         int             `yaml:"port"`
 406-	AnonAccess   string          `yaml:"anon-access"`
 407-	AllowKeyless bool            `yaml:"allow-keyless"`
 408-	Users        []User          `yaml:"users"`
 409-	Repos        []Repo          `yaml:"repos"`
 410-	Source       *git.RepoSource `yaml:"-"`
 411-	Cfg          *config.Config  `yaml:"-"`
 412-	mtx          sync.Mutex
 413-}
 414-
 415-// User contains user-level configuration for a repository.
 416-type User struct {
 417-	Name        string   `yaml:"name"`
 418-	Admin       bool     `yaml:"admin"`
 419-	PublicKeys  []string `yaml:"public-keys"`
 420-	CollabRepos []string `yaml:"collab-repos"`
 421-}
 422-
 423-// Repo contains repository configuration information.
 424-type Repo struct {
 425-	Name    string `yaml:"name"`
 426-	Repo    string `yaml:"repo"`
 427-	Note    string `yaml:"note"`
 428-	Private bool   `yaml:"private"`
 429-	Readme  string `yaml:"readme"`
 430-}
 431-
 432-// NewConfig creates a new internal Config struct.
 433-func NewConfig(cfg *config.Config) (*Config, error) {
 434-	var anonAccess string
 435-	var yamlUsers string
 436-	var displayHost string
 437-	host := cfg.Host
 438-	port := cfg.Port
 439-
 440-	pks := make([]string, 0)
 441-	for _, k := range cfg.InitialAdminKeys {
 442-		if bts, err := os.ReadFile(k); err == nil {
 443-			// pk is a file, set its contents as pk
 444-			k = string(bts)
 445-		}
 446-		var pk = strings.TrimSpace(k)
 447-		if pk == "" {
 448-			continue
 449-		}
 450-		// it is a valid ssh key, nothing to do
 451-		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
 452-			return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
 453-		}
 454-		pks = append(pks, pk)
 455-	}
 456-
 457-	rs := git.NewRepoSource(cfg.RepoPath)
 458-	c := &Config{
 459-		Cfg: cfg,
 460-	}
 461-	c.Host = cfg.Host
 462-	c.Port = port
 463-	c.Source = rs
 464-	if len(pks) == 0 {
 465-		anonAccess = "read-write"
 466-	} else {
 467-		anonAccess = "no-access"
 468-	}
 469-	if host == "" {
 470-		displayHost = "localhost"
 471-	} else {
 472diff --git a/internal/config/config_test.go b/internal/config/config_test.go
 473deleted file mode 100644
 474index 724cb5c8329588a1c68a7569c06c5e9d36b2383a..0000000000000000000000000000000000000000
 475--- a/internal/config/config_test.go
 476+++ /dev/null
 477@@ -1,37 +0,0 @@
 478-package config
 479-
 480-import (
 481-	"testing"
 482-
 483-	"github.com/charmbracelet/soft-serve/config"
 484-	"github.com/matryer/is"
 485-)
 486-
 487-func TestMultipleInitialKeys(t *testing.T) {
 488-	cfg, err := NewConfig(&config.Config{
 489-		RepoPath: t.TempDir(),
 490-		KeyPath:  t.TempDir(),
 491-		InitialAdminKeys: []string{
 492-			"testdata/k1.pub",
 493-			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
 494-		},
 495-	})
 496-	is := is.New(t)
 497-	is.NoErr(err)
 498-	err = cfg.Reload()
 499-	is.NoErr(err)
 500-	is.Equal(cfg.Users[0].PublicKeys, []string{
 501-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
 502-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
 503-	}) // should have both keys
 504-}
 505-
 506-func TestEmptyInitialKeys(t *testing.T) {
 507-	cfg, err := NewConfig(&config.Config{
 508-		RepoPath: t.TempDir(),
 509-		KeyPath:  t.TempDir(),
 510-	})
 511-	is := is.New(t)
 512-	is.NoErr(err)
 513-	is.Equal(len(cfg.Users), 0) // should not have any users
 514-}
 515diff --git a/internal/tui/bubble.go b/internal/tui/bubble.go
 516deleted file mode 100644
 517index 436dbc4db92ec62b0a84c49478aa9da1260dd5e4..0000000000000000000000000000000000000000
 518--- a/internal/tui/bubble.go
 519+++ /dev/null
 520@@ -1,234 +0,0 @@
 521-package tui
 522-
 523-import (
 524-	"fmt"
 525-	"strings"
 526-
 527-	tea "github.com/charmbracelet/bubbletea"
 528-	"github.com/charmbracelet/lipgloss"
 529-	"github.com/charmbracelet/soft-serve/internal/config"
 530-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 531-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 532-	"github.com/charmbracelet/soft-serve/internal/tui/style"
 533-	"github.com/charmbracelet/soft-serve/tui/common"
 534-	"github.com/gliderlabs/ssh"
 535-)
 536-
 537-const (
 538-	repoNameMaxWidth = 32
 539-)
 540-
 541-type sessionState int
 542-
 543-const (
 544-	startState sessionState = iota
 545-	errorState
 546-	loadedState
 547-	quittingState
 548-	quitState
 549-)
 550-
 551-type SessionConfig struct {
 552-	Width       int
 553-	Height      int
 554-	InitialRepo string
 555-	Session     ssh.Session
 556-}
 557-
 558-type MenuEntry struct {
 559-	Name   string `json:"name"`
 560-	Note   string `json:"note"`
 561-	Repo   string `json:"repo"`
 562-	bubble *repo.Bubble
 563-}
 564-
 565-type Bubble struct {
 566-	config      *config.Config
 567-	styles      *style.Styles
 568-	state       sessionState
 569-	error       string
 570-	width       int
 571-	height      int
 572-	initialRepo string
 573-	repoMenu    []MenuEntry
 574-	boxes       []tea.Model
 575-	activeBox   int
 576-	repoSelect  *selection.Bubble
 577-	session     ssh.Session
 578-
 579-	// remember the last resize so we can re-send it when selecting a different repo.
 580-	lastResize tea.WindowSizeMsg
 581-}
 582-
 583-func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
 584-	b := &Bubble{
 585-		config:      cfg,
 586-		styles:      style.DefaultStyles(),
 587-		width:       sCfg.Width,
 588-		height:      sCfg.Height,
 589-		repoMenu:    make([]MenuEntry, 0),
 590-		boxes:       make([]tea.Model, 2),
 591-		initialRepo: sCfg.InitialRepo,
 592-		session:     sCfg.Session,
 593-	}
 594-	b.state = startState
 595-	return b
 596-}
 597-
 598-func (b *Bubble) Init() tea.Cmd {
 599-	return b.setupCmd
 600-}
 601-
 602-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 603-	cmds := make([]tea.Cmd, 0)
 604-	switch msg := msg.(type) {
 605-	case tea.KeyMsg:
 606-		switch msg.String() {
 607-		case "q", "ctrl+c":
 608-			return b, tea.Quit
 609-		case "tab", "shift+tab":
 610-			b.activeBox = (b.activeBox + 1) % 2
 611-		}
 612-	case errMsg:
 613-		b.error = msg.Error()
 614-		b.state = errorState
 615-		return b, nil
 616-	case tea.WindowSizeMsg:
 617-		b.lastResize = msg
 618-		b.width = msg.Width
 619-		b.height = msg.Height
 620diff --git a/internal/tui/bubbles/repo/bubble.go b/internal/tui/bubbles/repo/bubble.go
 621deleted file mode 100644
 622index 6e9a34a99a99db365955758f5ae776f1dbfbdd22..0000000000000000000000000000000000000000
 623--- a/internal/tui/bubbles/repo/bubble.go
 624+++ /dev/null
 625@@ -1,137 +0,0 @@
 626-package repo
 627-
 628-import (
 629-	"fmt"
 630-	"strconv"
 631-
 632-	tea "github.com/charmbracelet/bubbletea"
 633-	"github.com/charmbracelet/lipgloss"
 634-	"github.com/charmbracelet/soft-serve/git"
 635-	"github.com/charmbracelet/soft-serve/internal/tui/style"
 636-	gitui "github.com/charmbracelet/soft-serve/tui"
 637-	"github.com/charmbracelet/soft-serve/tui/common"
 638-	"github.com/muesli/reflow/truncate"
 639-	"github.com/muesli/reflow/wrap"
 640-)
 641-
 642-const (
 643-	repoNameMaxWidth = 32
 644-)
 645-
 646-type Bubble struct {
 647-	name         string
 648-	host         string
 649-	port         int
 650-	repo         common.GitRepo
 651-	styles       *style.Styles
 652-	width        int
 653-	widthMargin  int
 654-	height       int
 655-	heightMargin int
 656-	box          *gitui.Bubble
 657-
 658-	Active bool
 659-}
 660-
 661-func NewBubble(repo common.GitRepo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble {
 662-	b := &Bubble{
 663-		name:         repo.Name(),
 664-		host:         host,
 665-		port:         port,
 666-		width:        width,
 667-		widthMargin:  wm,
 668-		height:       height,
 669-		heightMargin: hm,
 670-		styles:       styles,
 671-	}
 672-	b.repo = repo
 673-	b.box = gitui.NewBubble(repo, styles, width, wm+styles.RepoBody.GetHorizontalBorderSize(), height, hm+lipgloss.Height(b.headerView())-styles.RepoBody.GetVerticalBorderSize())
 674-	return b
 675-}
 676-
 677-func (b *Bubble) Init() tea.Cmd {
 678-	return b.box.Init()
 679-}
 680-
 681-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 682-	switch msg := msg.(type) {
 683-	case tea.WindowSizeMsg:
 684-		if msg.Width == b.width && msg.Height == b.height {
 685-			return b, nil
 686-		}
 687-		b.width = msg.Width
 688-		b.height = msg.Height
 689-	}
 690-	box, cmd := b.box.Update(msg)
 691-	b.box = box.(*gitui.Bubble)
 692-	return b, cmd
 693-}
 694-
 695-func (b *Bubble) Help() []common.HelpEntry {
 696-	return b.box.Help()
 697-}
 698-
 699-func (b Bubble) headerView() string {
 700-	// Render repo title
 701-	title := b.name
 702-	if title == "config" {
 703-		title = "Home"
 704-	}
 705-	title = truncate.StringWithTail(title, repoNameMaxWidth, "…")
 706-	title = b.styles.RepoTitle.Render(title)
 707-
 708-	// Render clone command
 709-	var note string
 710-	if b.name == "config" {
 711-		note = ""
 712-	} else {
 713-		note = fmt.Sprintf("git clone %s", b.sshAddress())
 714-	}
 715-	noteWidth := b.width -
 716-		b.widthMargin -
 717-		lipgloss.Width(title) -
 718-		b.styles.RepoTitleBox.GetHorizontalFrameSize()
 719-	// Hard-wrap the clone command only, without the usual word-wrapping. since
 720-	// a long repo name isn't going to be a series of space-separated "words",
 721-	// we'll always want it to be perfectly hard-wrapped.
 722-	note = wrap.String(note, noteWidth-b.styles.RepoNote.GetHorizontalFrameSize())
 723-	note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
 724-
 725diff --git a/internal/tui/bubbles/selection/bubble.go b/internal/tui/bubbles/selection/bubble.go
 726deleted file mode 100644
 727index 8be37dcf3b2a1e78974bc22c7c1caa830f5a8390..0000000000000000000000000000000000000000
 728--- a/internal/tui/bubbles/selection/bubble.go
 729+++ /dev/null
 730@@ -1,107 +0,0 @@
 731-package selection
 732-
 733-import (
 734-	"strings"
 735-
 736-	tea "github.com/charmbracelet/bubbletea"
 737-	"github.com/charmbracelet/lipgloss"
 738-	"github.com/charmbracelet/soft-serve/internal/tui/style"
 739-	"github.com/charmbracelet/soft-serve/tui/common"
 740-	"github.com/muesli/reflow/truncate"
 741-)
 742-
 743-type SelectedMsg struct {
 744-	Name  string
 745-	Index int
 746-}
 747-
 748-type ActiveMsg struct {
 749-	Name  string
 750-	Index int
 751-}
 752-
 753-type Bubble struct {
 754-	Items        []string
 755-	SelectedItem int
 756-	styles       *style.Styles
 757-}
 758-
 759-func NewBubble(items []string, styles *style.Styles) *Bubble {
 760-	return &Bubble{
 761-		Items:  items,
 762-		styles: styles,
 763-	}
 764-}
 765-
 766-func (b *Bubble) Init() tea.Cmd {
 767-	return nil
 768-}
 769-
 770-func (b Bubble) View() string {
 771-	s := strings.Builder{}
 772-	repoNameMaxWidth := b.styles.Menu.GetWidth() - // menu width
 773-		b.styles.Menu.GetHorizontalPadding() - // menu padding
 774-		lipgloss.Width(b.styles.MenuCursor.String()) - // cursor
 775-		b.styles.MenuItem.GetHorizontalFrameSize() // menu item gaps
 776-	for i, item := range b.Items {
 777-		item := truncate.StringWithTail(item, uint(repoNameMaxWidth), "…")
 778-		if i == b.SelectedItem {
 779-			s.WriteString(b.styles.MenuCursor.String())
 780-			s.WriteString(b.styles.SelectedMenuItem.Render(item))
 781-		} else {
 782-			s.WriteString(b.styles.MenuItem.Render(item))
 783-		}
 784-		if i < len(b.Items)-1 {
 785-			s.WriteRune('\n')
 786-		}
 787-	}
 788-	return s.String()
 789-}
 790-
 791-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 792-	cmds := make([]tea.Cmd, 0)
 793-	switch msg := msg.(type) {
 794-	case tea.KeyMsg:
 795-		switch msg.String() {
 796-		case "k", "up":
 797-			if b.SelectedItem > 0 {
 798-				b.SelectedItem--
 799-				cmds = append(cmds, b.sendActiveMessage)
 800-			}
 801-		case "j", "down":
 802-			if b.SelectedItem < len(b.Items)-1 {
 803-				b.SelectedItem++
 804-				cmds = append(cmds, b.sendActiveMessage)
 805-			}
 806-		case "enter":
 807-			cmds = append(cmds, b.sendSelectedMessage)
 808-		}
 809-	}
 810-	return b, tea.Batch(cmds...)
 811-}
 812-
 813-func (b *Bubble) Help() []common.HelpEntry {
 814-	return []common.HelpEntry{
 815-		{Key: "↑/↓", Value: "navigate"},
 816-	}
 817-}
 818-
 819-func (b *Bubble) sendActiveMessage() tea.Msg {
 820-	if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) {
 821-		return ActiveMsg{
 822-			Name:  b.Items[b.SelectedItem],
 823-			Index: b.SelectedItem,
 824-		}
 825-	}
 826-	return nil
 827-}
 828-
 829-func (b *Bubble) sendSelectedMessage() tea.Msg {
 830diff --git a/internal/tui/commands.go b/internal/tui/commands.go
 831deleted file mode 100644
 832index 5be3c9d17de291b7063d87290732a3710a721c19..0000000000000000000000000000000000000000
 833--- a/internal/tui/commands.go
 834+++ /dev/null
 835@@ -1,121 +0,0 @@
 836-package tui
 837-
 838-import (
 839-	"fmt"
 840-
 841-	tea "github.com/charmbracelet/bubbletea"
 842-	"github.com/charmbracelet/lipgloss"
 843-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 844-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 845-	"github.com/charmbracelet/soft-serve/tui/common"
 846-	gm "github.com/charmbracelet/wish/git"
 847-)
 848-
 849-type errMsg struct{ err error }
 850-
 851-func (e errMsg) Error() string {
 852-	return e.err.Error()
 853-}
 854-
 855-func (b *Bubble) setupCmd() tea.Msg {
 856-	if b.config == nil || b.config.Source == nil {
 857-		return errMsg{err: fmt.Errorf("config not set")}
 858-	}
 859-	mes, err := b.menuEntriesFromSource()
 860-	if err != nil {
 861-		return errMsg{err}
 862-	}
 863-	if len(mes) == 0 {
 864-		return errMsg{fmt.Errorf("no repos found")}
 865-	}
 866-	b.repoMenu = mes
 867-	rs := make([]string, 0)
 868-	for _, m := range mes {
 869-		rs = append(rs, m.Name)
 870-	}
 871-	b.repoSelect = selection.NewBubble(rs, b.styles)
 872-	b.boxes[0] = b.repoSelect
 873-
 874-	// Jump to an initial repo
 875-	ir := -1
 876-	if b.initialRepo != "" {
 877-		for i, me := range b.repoMenu {
 878-			if me.Repo == b.initialRepo {
 879-				ir = i
 880-			}
 881-		}
 882-	}
 883-	if ir == -1 {
 884-		b.boxes[1] = b.repoMenu[0].bubble
 885-		b.activeBox = 0
 886-	} else {
 887-		b.boxes[1] = b.repoMenu[ir].bubble
 888-		b.repoSelect.SelectedItem = ir
 889-		b.activeBox = 1
 890-	}
 891-
 892-	b.state = loadedState
 893-	return nil
 894-}
 895-
 896-func (b *Bubble) menuEntriesFromSource() ([]MenuEntry, error) {
 897-	mes := make([]MenuEntry, 0)
 898-	for _, cr := range b.config.Repos {
 899-		acc := b.config.AuthRepo(cr.Repo, b.session.PublicKey())
 900-		if acc == gm.NoAccess && cr.Repo != "config" {
 901-			continue
 902-		}
 903-		if cr.Private && acc < gm.ReadOnlyAccess {
 904-			continue
 905-		}
 906-		me, err := b.newMenuEntry(cr.Name, cr.Repo)
 907-		if err != nil {
 908-			return nil, err
 909-		}
 910-		mes = append(mes, me)
 911-	}
 912-	for _, r := range b.config.Source.AllRepos() {
 913-		var found bool
 914-		rn := r.Name()
 915-		for _, me := range mes {
 916-			if me.Repo == rn {
 917-				found = true
 918-			}
 919-		}
 920-		if !found {
 921-			acc := b.config.AuthRepo(rn, b.session.PublicKey())
 922-			if acc == gm.NoAccess {
 923-				continue
 924-			}
 925-			me, err := b.newMenuEntry(rn, rn)
 926-			if err != nil {
 927-				return nil, err
 928-			}
 929-			mes = append(mes, me)
 930-		}
 931-	}
 932-	return mes, nil
 933-}
 934-
 935diff --git a/internal/tui/session.go b/internal/tui/session.go
 936deleted file mode 100644
 937index 7501769e1c752703dd0b4d6e4e1779f5b3688bd9..0000000000000000000000000000000000000000
 938--- a/internal/tui/session.go
 939+++ /dev/null
 940@@ -1,37 +0,0 @@
 941-package tui
 942-
 943-import (
 944-	"fmt"
 945-
 946-	tea "github.com/charmbracelet/bubbletea"
 947-	"github.com/charmbracelet/soft-serve/internal/config"
 948-	"github.com/gliderlabs/ssh"
 949-)
 950-
 951-// SessionHandler handles the bubble tea session.
 952-func SessionHandler(cfg *config.Config) func(ssh.Session) (tea.Model, []tea.ProgramOption) {
 953-	return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
 954-		pty, _, active := s.Pty()
 955-		if !active {
 956-			fmt.Println("not active")
 957-			return nil, nil
 958-		}
 959-		cmd := s.Command()
 960-		scfg := &SessionConfig{Session: s}
 961-		switch len(cmd) {
 962-		case 0:
 963-			scfg.InitialRepo = ""
 964-		case 1:
 965-			scfg.InitialRepo = cmd[0]
 966-		}
 967-		scfg.Width = pty.Window.Width
 968-		scfg.Height = pty.Window.Height
 969-		if cfg.Cfg.Callbacks != nil {
 970-			cfg.Cfg.Callbacks.Tui("view")
 971-		}
 972-		return NewBubble(cfg, scfg), []tea.ProgramOption{
 973-			tea.WithAltScreen(),
 974-			tea.WithoutCatchPanics(),
 975-		}
 976-	}
 977-}
 978diff --git a/server/cmd/cat.go b/server/cmd/cat.go
 979index 18ff5678c982d9e4b73cab235c331c9d57d78d3b..75008918675b09849c19b662e83b0ccf6e205f34 100644
 980--- a/server/cmd/cat.go
 981+++ b/server/cmd/cat.go
 982@@ -7,8 +7,8 @@ import (
 983 	"github.com/alecthomas/chroma/lexers"
 984 	gansi "github.com/charmbracelet/glamour/ansi"
 985 	"github.com/charmbracelet/lipgloss"
 986-	"github.com/charmbracelet/soft-serve/internal/git"
 987-	"github.com/charmbracelet/soft-serve/tui/common"
 988+	"github.com/charmbracelet/soft-serve/config"
 989+	"github.com/charmbracelet/soft-serve/ui/common"
 990 	gitwish "github.com/charmbracelet/wish/git"
 991 	"github.com/muesli/termenv"
 992 	"github.com/spf13/cobra"
 993@@ -40,10 +40,10 @@ func CatCommand() *cobra.Command {
 994 			if auth < gitwish.ReadOnlyAccess {
 995 				return ErrUnauthorized
 996 			}
 997-			var repo *git.Repo
 998+			var repo *config.Repo
 999 			repoExists := false
1000 			for _, rp := range ac.Source.AllRepos() {
1001-				if rp.Name() == rn {
1002+				if rp.Repo() == rn {
1003 					repoExists = true
1004 					repo = rp
1005 					break
1006@@ -109,7 +109,7 @@ func withFormatting(p, c string) (string, error) {
1007 		Language: lang,
1008 	}
1009 	r := strings.Builder{}
1010-	styles := common.DefaultStyles()
1011+	styles := common.StyleConfig()
1012 	styles.CodeBlock.Margin = &zero
1013 	rctx := gansi.NewRenderContext(gansi.Options{
1014 		Styles:       styles,
1015diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go
1016index 157a1c2bbbe82fd3f0d3b398e2050fe4c4281e20..684fe7ec65fb17559bceb05151208158b02bb134 100644
1017--- a/server/cmd/cmd.go
1018+++ b/server/cmd/cmd.go
1019@@ -3,11 +3,26 @@ package cmd
1020 import (
1021 	"fmt"
1022 
1023-	appCfg "github.com/charmbracelet/soft-serve/internal/config"
1024+	appCfg "github.com/charmbracelet/soft-serve/config"
1025 	"github.com/gliderlabs/ssh"
1026 	"github.com/spf13/cobra"
1027 )
1028 
1029+// ContextKey is a type that can be used as a key in a context.
1030+type ContextKey string
1031+
1032+// String returns the string representation of the ContextKey.
1033+func (c ContextKey) String() string {
1034+	return "soft-serve cli context key " + string(c)
1035+}
1036+
1037+var (
1038+	// ConfigCtxKey is the key for the config in the context.
1039+	ConfigCtxKey = ContextKey("config")
1040+	// SessionCtxKey is the key for the session in the context.
1041+	SessionCtxKey = ContextKey("session")
1042+)
1043+
1044 var (
1045 	// ErrUnauthorized is returned when the user is not authorized to perform action.
1046 	ErrUnauthorized = fmt.Errorf("Unauthorized")
1047@@ -64,7 +79,7 @@ func RootCommand() *cobra.Command {
1048 
1049 func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) {
1050 	ctx := cmd.Context()
1051-	ac := ctx.Value("config").(*appCfg.Config)
1052-	s := ctx.Value("session").(ssh.Session)
1053+	ac := ctx.Value(ConfigCtxKey).(*appCfg.Config)
1054+	s := ctx.Value(SessionCtxKey).(ssh.Session)
1055 	return ac, s
1056 }
1057diff --git a/server/cmd/git.go b/server/cmd/git.go
1058index fd2c3be87505141eb56f3c2dd625fcd43097c84f..d9a60070e6bdbf3ab3d1419d758bf93be75fc2c8 100644
1059--- a/server/cmd/git.go
1060+++ b/server/cmd/git.go
1061@@ -4,7 +4,7 @@ import (
1062 	"io"
1063 	"os/exec"
1064 
1065-	"github.com/charmbracelet/soft-serve/internal/git"
1066+	"github.com/charmbracelet/soft-serve/config"
1067 	gitwish "github.com/charmbracelet/wish/git"
1068 	"github.com/spf13/cobra"
1069 )
1070@@ -23,11 +23,11 @@ func GitCommand() *cobra.Command {
1071 			if len(args) < 1 {
1072 				return runGit(nil, s, s, "")
1073 			}
1074-			var repo *git.Repo
1075+			var repo *config.Repo
1076 			rn := args[0]
1077 			repoExists := false
1078 			for _, rp := range ac.Source.AllRepos() {
1079-				if rp.Name() == rn {
1080+				if rp.Repo() == rn {
1081 					repoExists = true
1082 					repo = rp
1083 					break
1084diff --git a/server/cmd/list.go b/server/cmd/list.go
1085index f07a2a68f3600357e0020ea6269a954714740220..e1b239b186f8d453fbbc810efa4b3c07d630f4b8 100644
1086--- a/server/cmd/list.go
1087+++ b/server/cmd/list.go
1088@@ -33,7 +33,7 @@ func ListCommand() *cobra.Command {
1089 			}
1090 			if path == "" || path == "." || path == "/" {
1091 				for _, r := range ac.Source.AllRepos() {
1092-					fmt.Fprintln(s, r.Name())
1093+					fmt.Fprintln(s, r.Repo())
1094 				}
1095 				return nil
1096 			}
1097diff --git a/server/config/config.go b/server/config/config.go
1098new file mode 100644
1099index 0000000000000000000000000000000000000000..dc60e2dad1f2cd802a98d1ca3894c6db4bc0d8ba
1100--- /dev/null
1101+++ b/server/config/config.go
1102@@ -0,0 +1,53 @@
1103+package config
1104+
1105+import (
1106+	"log"
1107+	"path/filepath"
1108+
1109+	"github.com/caarlos0/env/v6"
1110+)
1111+
1112+// Callbacks provides an interface that can be used to run callbacks on different events.
1113+type Callbacks interface {
1114+	Tui(action string)
1115+	Push(repo string)
1116+	Fetch(repo string)
1117+}
1118+
1119+// Config is the configuration for Soft Serve.
1120+type Config struct {
1121+	BindAddr         string   `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
1122+	Host             string   `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
1123+	Port             int      `env:"SOFT_SERVE_PORT" envDefault:"23231"`
1124+	KeyPath          string   `env:"SOFT_SERVE_KEY_PATH"`
1125+	RepoPath         string   `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
1126+	InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
1127+	Callbacks        Callbacks
1128+	ErrorLog         *log.Logger
1129+}
1130+
1131+// DefaultConfig returns a Config with the values populated with the defaults
1132+// or specified environment variables.
1133+func DefaultConfig() *Config {
1134+	cfg := &Config{ErrorLog: log.Default()}
1135+	if err := env.Parse(cfg); err != nil {
1136+		log.Fatalln(err)
1137+	}
1138+	if cfg.KeyPath == "" {
1139+		// NB: cross-platform-compatible path
1140+		cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
1141+	}
1142+	return cfg.WithCallbacks(nil)
1143+}
1144+
1145+// WithCallbacks applies the given Callbacks to the configuration.
1146+func (c *Config) WithCallbacks(callbacks Callbacks) *Config {
1147+	c.Callbacks = callbacks
1148+	return c
1149+}
1150+
1151+// WithErrorLogger sets the error logger for the configuration.
1152+func (c *Config) WithErrorLogger(logger *log.Logger) *Config {
1153+	c.ErrorLog = logger
1154+	return c
1155+}
1156diff --git a/server/config/config_test.go b/server/config/config_test.go
1157new file mode 100644
1158index 0000000000000000000000000000000000000000..6a8eb7e4890f4ff535b7523491936a6273590755
1159--- /dev/null
1160+++ b/server/config/config_test.go
1161@@ -0,0 +1,19 @@
1162+package config
1163+
1164+import (
1165+	"os"
1166+	"testing"
1167+
1168+	"github.com/matryer/is"
1169+)
1170+
1171+func TestParseMultipleKeys(t *testing.T) {
1172+	is := is.New(t)
1173+	is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
1174+	t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
1175+	cfg := DefaultConfig()
1176+	is.Equal(cfg.InitialAdminKeys, []string{
1177+		"testdata/k1.pub",
1178+		"testdata/k2.pub",
1179+	})
1180+}
1181diff --git a/server/middleware.go b/server/middleware.go
1182index 44ea5c7d612899a68c6e258832d132eba7071d9e..a4969f5fe5582929bc7ed6ddca571d225c4514d0 100644
1183--- a/server/middleware.go
1184+++ b/server/middleware.go
1185@@ -4,7 +4,7 @@ import (
1186 	"context"
1187 	"fmt"
1188 
1189-	appCfg "github.com/charmbracelet/soft-serve/internal/config"
1190+	appCfg "github.com/charmbracelet/soft-serve/config"
1191 	"github.com/charmbracelet/soft-serve/server/cmd"
1192 	"github.com/charmbracelet/wish"
1193 	"github.com/gliderlabs/ssh"
1194@@ -19,8 +19,8 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware {
1195 				if active {
1196 					return
1197 				}
1198-				ctx := context.WithValue(s.Context(), "config", ac) //nolint:revive
1199-				ctx = context.WithValue(ctx, "session", s)          //nolint:revive
1200+				ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac)
1201+				ctx = context.WithValue(ctx, cmd.SessionCtxKey, s)
1202 
1203 				use := "ssh"
1204 				port := ac.Port
1205diff --git a/server/middleware_test.go b/server/middleware_test.go
1206index 94ac3ebbfef8dd25bbdcf392ec31e48f4f6301f4..6895d3d10d9483fb0b202973a84e388a5f8a6bc8 100644
1207--- a/server/middleware_test.go
1208+++ b/server/middleware_test.go
1209@@ -4,8 +4,8 @@ import (
1210 	"os"
1211 	"testing"
1212 
1213-	sconfig "github.com/charmbracelet/soft-serve/config"
1214-	"github.com/charmbracelet/soft-serve/internal/config"
1215+	"github.com/charmbracelet/soft-serve/config"
1216+	sconfig "github.com/charmbracelet/soft-serve/server/config"
1217 	"github.com/charmbracelet/wish/testsession"
1218 	"github.com/gliderlabs/ssh"
1219 	"github.com/matryer/is"
1220diff --git a/server/server.go b/server/server.go
1221index 708aa3e2fa0c1b261ddc6456f7569e143b5e45eb..cbf40fd66a1f4aacd8e74c9677e05bbb52496d23 100644
1222--- a/server/server.go
1223+++ b/server/server.go
1224@@ -6,15 +6,15 @@ import (
1225 	"log"
1226 	"net"
1227 
1228-	"github.com/charmbracelet/soft-serve/config"
1229-	appCfg "github.com/charmbracelet/soft-serve/internal/config"
1230-	"github.com/charmbracelet/soft-serve/internal/tui"
1231+	appCfg "github.com/charmbracelet/soft-serve/config"
1232+	"github.com/charmbracelet/soft-serve/server/config"
1233 	"github.com/charmbracelet/wish"
1234 	bm "github.com/charmbracelet/wish/bubbletea"
1235 	gm "github.com/charmbracelet/wish/git"
1236 	lm "github.com/charmbracelet/wish/logging"
1237 	rm "github.com/charmbracelet/wish/recover"
1238 	"github.com/gliderlabs/ssh"
1239+	"github.com/muesli/termenv"
1240 )
1241 
1242 // Server is the Soft Serve server.
1243@@ -37,10 +37,10 @@ func NewServer(cfg *config.Config) *Server {
1244 	mw := []wish.Middleware{
1245 		rm.MiddlewareWithLogger(
1246 			cfg.ErrorLog,
1247-			lm.Middleware(),
1248 			softMiddleware(ac),
1249-			bm.Middleware(tui.SessionHandler(ac)),
1250+			bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
1251 			gm.Middleware(cfg.RepoPath, ac),
1252+			lm.Middleware(),
1253 		),
1254 	}
1255 	s, err := wish.NewServer(
1256diff --git a/server/server_test.go b/server/server_test.go
1257index 0756e7f53d6ea1c9e8c5bf382c6de4d5ad71802c..d7dcf9b8b21cedaba940b88c709c172b94bd9bfc 100644
1258--- a/server/server_test.go
1259+++ b/server/server_test.go
1260@@ -7,7 +7,7 @@ import (
1261 	"testing"
1262 
1263 	"github.com/charmbracelet/keygen"
1264-	"github.com/charmbracelet/soft-serve/config"
1265+	"github.com/charmbracelet/soft-serve/server/config"
1266 	"github.com/gliderlabs/ssh"
1267 	"github.com/go-git/go-git/v5"
1268 	gconfig "github.com/go-git/go-git/v5/config"
1269diff --git a/server/session.go b/server/session.go
1270new file mode 100644
1271index 0000000000000000000000000000000000000000..de6f2d4c3d33a0fb2feb01181c8b5d6d04a3dc12
1272--- /dev/null
1273+++ b/server/session.go
1274@@ -0,0 +1,57 @@
1275+package server
1276+
1277+import (
1278+	"fmt"
1279+
1280+	"github.com/aymanbagabas/go-osc52"
1281+	tea "github.com/charmbracelet/bubbletea"
1282+	appCfg "github.com/charmbracelet/soft-serve/config"
1283+	"github.com/charmbracelet/soft-serve/ui"
1284+	"github.com/charmbracelet/soft-serve/ui/common"
1285+	"github.com/charmbracelet/soft-serve/ui/keymap"
1286+	"github.com/charmbracelet/soft-serve/ui/styles"
1287+	bm "github.com/charmbracelet/wish/bubbletea"
1288+	"github.com/gliderlabs/ssh"
1289+)
1290+
1291+// SessionHandler is the soft-serve bubbletea ssh session handler.
1292+func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
1293+	return func(s ssh.Session) *tea.Program {
1294+		pty, _, active := s.Pty()
1295+		if !active {
1296+			return nil
1297+		}
1298+		cmd := s.Command()
1299+		initialRepo := ""
1300+		if len(cmd) == 1 {
1301+			initialRepo = cmd[0]
1302+		}
1303+		if ac.Cfg.Callbacks != nil {
1304+			ac.Cfg.Callbacks.Tui("new session")
1305+		}
1306+		envs := s.Environ()
1307+		envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term))
1308+		output := osc52.NewOutput(s, envs)
1309+		c := common.Common{
1310+			Copy:   output,
1311+			Styles: styles.DefaultStyles(),
1312+			KeyMap: keymap.DefaultKeyMap(),
1313+			Width:  pty.Window.Width,
1314+			Height: pty.Window.Height,
1315+		}
1316+		m := ui.New(
1317+			ac,
1318+			s,
1319+			c,
1320+			initialRepo,
1321+		)
1322+		p := tea.NewProgram(m,
1323+			tea.WithInput(s),
1324+			tea.WithOutput(s),
1325+			tea.WithAltScreen(),
1326+			tea.WithoutCatchPanics(),
1327+			tea.WithMouseCellMotion(),
1328+		)
1329+		return p
1330+	}
1331+}
1332diff --git a/tui/about/bubble.go b/tui/about/bubble.go
1333deleted file mode 100644
1334index 9205268e97a49a16846cd81c4cea4d1fd28800e9..0000000000000000000000000000000000000000
1335--- a/tui/about/bubble.go
1336+++ /dev/null
1337@@ -1,122 +0,0 @@
1338-package about
1339-
1340-import (
1341-	"github.com/charmbracelet/bubbles/viewport"
1342-	tea "github.com/charmbracelet/bubbletea"
1343-	"github.com/charmbracelet/soft-serve/git"
1344-	"github.com/charmbracelet/soft-serve/internal/tui/style"
1345-	"github.com/charmbracelet/soft-serve/tui/common"
1346-	"github.com/charmbracelet/soft-serve/tui/refs"
1347-	vp "github.com/charmbracelet/soft-serve/tui/viewport"
1348-	"github.com/muesli/reflow/wrap"
1349-)
1350-
1351-type Bubble struct {
1352-	readmeViewport *vp.ViewportBubble
1353-	repo           common.GitRepo
1354-	styles         *style.Styles
1355-	height         int
1356-	heightMargin   int
1357-	width          int
1358-	widthMargin    int
1359-	ref            *git.Reference
1360-}
1361-
1362-func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1363-	b := &Bubble{
1364-		readmeViewport: &vp.ViewportBubble{
1365-			Viewport: &viewport.Model{},
1366-		},
1367-		repo:         repo,
1368-		styles:       styles,
1369-		widthMargin:  wm,
1370-		heightMargin: hm,
1371-	}
1372-	b.SetSize(width, height)
1373-	return b
1374-}
1375-func (b *Bubble) Init() tea.Cmd {
1376-	return b.reset
1377-}
1378-
1379-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1380-	var cmds []tea.Cmd
1381-	switch msg := msg.(type) {
1382-	case tea.WindowSizeMsg:
1383-		b.SetSize(msg.Width, msg.Height)
1384-		// XXX: if we find that longer readmes take more than a few
1385-		// milliseconds to render we may need to move Glamour rendering into a
1386-		// command.
1387-		md, err := b.glamourize()
1388-		if err != nil {
1389-			return b, nil
1390-		}
1391-		b.readmeViewport.Viewport.SetContent(md)
1392-	case tea.KeyMsg:
1393-		switch msg.String() {
1394-		case "R":
1395-			return b, b.reset
1396-		}
1397-	case refs.RefMsg:
1398-		b.ref = msg
1399-		return b, b.reset
1400-	}
1401-	rv, cmd := b.readmeViewport.Update(msg)
1402-	b.readmeViewport = rv.(*vp.ViewportBubble)
1403-	cmds = append(cmds, cmd)
1404-	return b, tea.Batch(cmds...)
1405-}
1406-
1407-func (b *Bubble) SetSize(w, h int) {
1408-	b.width = w
1409-	b.height = h
1410-	b.readmeViewport.Viewport.Width = w - b.widthMargin
1411-	b.readmeViewport.Viewport.Height = h - b.heightMargin
1412-}
1413-
1414-func (b *Bubble) GotoTop() {
1415-	b.readmeViewport.Viewport.GotoTop()
1416-}
1417-
1418-func (b *Bubble) View() string {
1419-	return b.readmeViewport.View()
1420-}
1421-
1422-func (b *Bubble) Help() []common.HelpEntry {
1423-	return nil
1424-}
1425-
1426-func (b *Bubble) glamourize() (string, error) {
1427-	w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
1428-	rm, rp := b.repo.Readme()
1429-	if rm == "" {
1430-		return b.styles.AboutNoReadme.Render("No readme found."), nil
1431-	}
1432-	f, err := common.RenderFile(rp, rm, w)
1433-	if err != nil {
1434-		return "", err
1435-	}
1436-	// For now, hard-wrap long lines in Glamour that would otherwise break the
1437diff --git a/tui/bubble.go b/tui/bubble.go
1438deleted file mode 100644
1439index e23442472c4260ccae1a77523e1e8071212380d9..0000000000000000000000000000000000000000
1440--- a/tui/bubble.go
1441+++ /dev/null
1442@@ -1,155 +0,0 @@
1443-package tui
1444-
1445-import (
1446-	tea "github.com/charmbracelet/bubbletea"
1447-	"github.com/charmbracelet/lipgloss"
1448-	"github.com/charmbracelet/soft-serve/git"
1449-	"github.com/charmbracelet/soft-serve/internal/tui/style"
1450-	"github.com/charmbracelet/soft-serve/tui/about"
1451-	"github.com/charmbracelet/soft-serve/tui/common"
1452-	"github.com/charmbracelet/soft-serve/tui/log"
1453-	"github.com/charmbracelet/soft-serve/tui/refs"
1454-	"github.com/charmbracelet/soft-serve/tui/tree"
1455-)
1456-
1457-const (
1458-	repoNameMaxWidth = 32
1459-)
1460-
1461-type state int
1462-
1463-const (
1464-	aboutState state = iota
1465-	refsState
1466-	logState
1467-	treeState
1468-)
1469-
1470-type Bubble struct {
1471-	state        state
1472-	repo         common.GitRepo
1473-	height       int
1474-	heightMargin int
1475-	width        int
1476-	widthMargin  int
1477-	style        *style.Styles
1478-	boxes        []tea.Model
1479-	ref          *git.Reference
1480-}
1481-
1482-func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1483-	b := &Bubble{
1484-		repo:         repo,
1485-		state:        aboutState,
1486-		width:        width,
1487-		widthMargin:  wm,
1488-		height:       height,
1489-		heightMargin: hm,
1490-		style:        styles,
1491-		boxes:        make([]tea.Model, 4),
1492-	}
1493-	heightMargin := hm + lipgloss.Height(b.headerView())
1494-	b.boxes[aboutState] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
1495-	b.boxes[refsState] = refs.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
1496-	b.boxes[logState] = log.NewBubble(repo, b.style, width, wm, height, heightMargin)
1497-	b.boxes[treeState] = tree.NewBubble(repo, b.style, width, wm, height, heightMargin)
1498-	return b
1499-}
1500-
1501-func (b *Bubble) Init() tea.Cmd {
1502-	return b.setupCmd
1503-}
1504-
1505-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1506-	cmds := make([]tea.Cmd, 0)
1507-	switch msg := msg.(type) {
1508-	case tea.KeyMsg:
1509-		if b.repo.Name() != "config" {
1510-			switch msg.String() {
1511-			case "R":
1512-				b.state = aboutState
1513-			case "B":
1514-				b.state = refsState
1515-			case "C":
1516-				b.state = logState
1517-			case "F":
1518-				b.state = treeState
1519-			}
1520-		}
1521-	case tea.WindowSizeMsg:
1522-		b.width = msg.Width
1523-		b.height = msg.Height
1524-		for i, bx := range b.boxes {
1525-			m, cmd := bx.Update(msg)
1526-			b.boxes[i] = m
1527-			if cmd != nil {
1528-				cmds = append(cmds, cmd)
1529-			}
1530-		}
1531-	case refs.RefMsg:
1532-		b.state = treeState
1533-		b.ref = msg
1534-		for i, bx := range b.boxes {
1535-			m, cmd := bx.Update(msg)
1536-			b.boxes[i] = m
1537-			if cmd != nil {
1538-				cmds = append(cmds, cmd)
1539-			}
1540-		}
1541-	}
1542diff --git a/tui/common/consts.go b/tui/common/consts.go
1543deleted file mode 100644
1544index c915df177fbfe6ca0c6c67c58ae246b39d633f6a..0000000000000000000000000000000000000000
1545--- a/tui/common/consts.go
1546+++ /dev/null
1547@@ -1,28 +0,0 @@
1548-package common
1549-
1550-import (
1551-	"time"
1552-
1553-	"github.com/charmbracelet/bubbles/key"
1554-)
1555-
1556-// Some constants were copied from https://docs.gitea.io/en-us/config-cheat-sheet/#git-git
1557-
1558-const (
1559-	GlamourMaxWidth  = 120
1560-	RepoNameMaxWidth = 32
1561-	MaxDiffLines     = 1000
1562-	MaxDiffFiles     = 100
1563-	MaxPatchWait     = time.Second * 3
1564-)
1565-
1566-var (
1567-	PrevPage = key.NewBinding(
1568-		key.WithKeys("pgup", "b", "u"),
1569-		key.WithHelp("pgup", "prev page"),
1570-	)
1571-	NextPage = key.NewBinding(
1572-		key.WithKeys("pgdown", "f", "d"),
1573-		key.WithHelp("pgdn", "next page"),
1574-	)
1575-)
1576diff --git a/tui/common/error.go b/tui/common/error.go
1577deleted file mode 100644
1578index b9ecc9594b04ed7a793fee776af2f3835ffa6715..0000000000000000000000000000000000000000
1579--- a/tui/common/error.go
1580+++ /dev/null
1581@@ -1,36 +0,0 @@
1582-package common
1583-
1584-import (
1585-	"errors"
1586-
1587-	"github.com/charmbracelet/lipgloss"
1588-	"github.com/charmbracelet/soft-serve/internal/tui/style"
1589-)
1590-
1591-var (
1592-	ErrDiffTooLong      = errors.New("diff is too long")
1593-	ErrDiffFilesTooLong = errors.New("diff files are too long")
1594-	ErrBinaryFile       = errors.New("binary file")
1595-	ErrFileTooLarge     = errors.New("file is too large")
1596-	ErrInvalidFile      = errors.New("invalid file")
1597-)
1598-
1599-type ErrMsg struct {
1600-	Err error
1601-}
1602-
1603-func (e ErrMsg) Error() string {
1604-	return e.Err.Error()
1605-}
1606-
1607-func (e ErrMsg) View(s *style.Styles) string {
1608-	return e.ViewWithPrefix(s, "")
1609-}
1610-
1611-func (e ErrMsg) ViewWithPrefix(s *style.Styles, prefix string) string {
1612-	return lipgloss.JoinHorizontal(
1613-		lipgloss.Top,
1614-		s.ErrorTitle.Render(prefix),
1615-		s.ErrorBody.Render(e.Error()),
1616-	)
1617-}
1618diff --git a/tui/common/formatter.go b/tui/common/formatter.go
1619deleted file mode 100644
1620index 11143ec48a2694437cf63ae9e7728c2ad61198dc..0000000000000000000000000000000000000000
1621--- a/tui/common/formatter.go
1622+++ /dev/null
1623@@ -1,88 +0,0 @@
1624-package common
1625-
1626-import (
1627-	"strings"
1628-
1629-	"github.com/alecthomas/chroma/lexers"
1630-	"github.com/charmbracelet/glamour"
1631-	gansi "github.com/charmbracelet/glamour/ansi"
1632-	"github.com/muesli/termenv"
1633-)
1634-
1635-var (
1636-	RenderCtx = DefaultRenderCtx()
1637-	Styles    = DefaultStyles()
1638-)
1639-
1640-func DefaultStyles() gansi.StyleConfig {
1641-	noColor := ""
1642-	s := glamour.DarkStyleConfig
1643-	s.Document.StylePrimitive.Color = &noColor
1644-	s.CodeBlock.Chroma.Text.Color = &noColor
1645-	s.CodeBlock.Chroma.Name.Color = &noColor
1646-	return s
1647-}
1648-
1649-func DefaultRenderCtx() gansi.RenderContext {
1650-	return gansi.NewRenderContext(gansi.Options{
1651-		ColorProfile: termenv.TrueColor,
1652-		Styles:       DefaultStyles(),
1653-	})
1654-}
1655-
1656-func NewRenderCtx(worldwrap int) gansi.RenderContext {
1657-	return gansi.NewRenderContext(gansi.Options{
1658-		ColorProfile: termenv.TrueColor,
1659-		Styles:       DefaultStyles(),
1660-		WordWrap:     worldwrap,
1661-	})
1662-}
1663-
1664-func Glamourize(w int, md string) (string, error) {
1665-	if w > GlamourMaxWidth {
1666-		w = GlamourMaxWidth
1667-	}
1668-	tr, err := glamour.NewTermRenderer(
1669-		glamour.WithStyles(DefaultStyles()),
1670-		glamour.WithWordWrap(w),
1671-	)
1672-
1673-	if err != nil {
1674-		return "", err
1675-	}
1676-	mdt, err := tr.Render(md)
1677-	if err != nil {
1678-		return "", err
1679-	}
1680-	return mdt, nil
1681-}
1682-
1683-func RenderFile(path, content string, width int) (string, error) {
1684-	lexer := lexers.Fallback
1685-	if path == "" {
1686-		lexer = lexers.Analyse(content)
1687-	} else {
1688-		lexer = lexers.Match(path)
1689-	}
1690-	lang := ""
1691-	if lexer != nil && lexer.Config() != nil {
1692-		lang = lexer.Config().Name
1693-	}
1694-	formatter := &gansi.CodeBlockElement{
1695-		Code:     content,
1696-		Language: lang,
1697-	}
1698-	if lang == "markdown" {
1699-		md, err := Glamourize(width, content)
1700-		if err != nil {
1701-			return "", err
1702-		}
1703-		return md, nil
1704-	}
1705-	r := strings.Builder{}
1706-	err := formatter.Render(&r, RenderCtx)
1707-	if err != nil {
1708-		return "", err
1709-	}
1710-	return r.String(), nil
1711-}
1712diff --git a/tui/common/git.go b/tui/common/git.go
1713deleted file mode 100644
1714index 43cb53ca46a632ef64b7685a5186ef9070449f0f..0000000000000000000000000000000000000000
1715--- a/tui/common/git.go
1716+++ /dev/null
1717@@ -1,16 +0,0 @@
1718-package common
1719-
1720-import (
1721-	"github.com/charmbracelet/soft-serve/git"
1722-)
1723-
1724-type GitRepo interface {
1725-	Name() string
1726-	Readme() (string, string)
1727-	HEAD() (*git.Reference, error)
1728-	CommitsByPage(*git.Reference, int, int) (git.Commits, error)
1729-	CountCommits(*git.Reference) (int64, error)
1730-	Diff(*git.Commit) (*git.Diff, error)
1731-	References() ([]*git.Reference, error)
1732-	Tree(*git.Reference, string) (*git.Tree, error)
1733-}
1734diff --git a/tui/common/help.go b/tui/common/help.go
1735deleted file mode 100644
1736index 5bc1a9a8df577879b048fa0bceee2dad4089676d..0000000000000000000000000000000000000000
1737--- a/tui/common/help.go
1738+++ /dev/null
1739@@ -1,10 +0,0 @@
1740-package common
1741-
1742-type BubbleHelper interface {
1743-	Help() []HelpEntry
1744-}
1745-
1746-type HelpEntry struct {
1747-	Key   string
1748-	Value string
1749-}
1750diff --git a/tui/common/reset.go b/tui/common/reset.go
1751deleted file mode 100644
1752index fe92b47154c9fe0850563dea7e9f87550c281c36..0000000000000000000000000000000000000000
1753--- a/tui/common/reset.go
1754+++ /dev/null
1755@@ -1,7 +0,0 @@
1756-package common
1757-
1758-import tea "github.com/charmbracelet/bubbletea"
1759-
1760-type BubbleReset interface {
1761-	Reset() tea.Msg
1762-}
1763diff --git a/tui/common/utils.go b/tui/common/utils.go
1764deleted file mode 100644
1765index 2cafee64e60735a350363506c578dcf2bb9cb193..0000000000000000000000000000000000000000
1766--- a/tui/common/utils.go
1767+++ /dev/null
1768@@ -1,17 +0,0 @@
1769-package common
1770-
1771-import "github.com/muesli/reflow/truncate"
1772-
1773-func TruncateString(s string, max int, tail string) string {
1774-	if max < 0 {
1775-		max = 0
1776-	}
1777-	return truncate.StringWithTail(s, uint(max), tail)
1778-}
1779-
1780-func Max(a, b int) int {
1781-	if a > b {
1782-		return a
1783-	}
1784-	return b
1785-}
1786diff --git a/tui/log/bubble.go b/tui/log/bubble.go
1787deleted file mode 100644
1788index 30c35069b9930fdb4b97c27e0d8b77117c9fe655..0000000000000000000000000000000000000000
1789--- a/tui/log/bubble.go
1790+++ /dev/null
1791@@ -1,383 +0,0 @@
1792-package log
1793-
1794-import (
1795-	"fmt"
1796-	"io"
1797-	"strings"
1798-	"time"
1799-
1800-	"github.com/charmbracelet/bubbles/list"
1801-	"github.com/charmbracelet/bubbles/spinner"
1802-	"github.com/charmbracelet/bubbles/viewport"
1803-	tea "github.com/charmbracelet/bubbletea"
1804-	gansi "github.com/charmbracelet/glamour/ansi"
1805-	"github.com/charmbracelet/soft-serve/git"
1806-	"github.com/charmbracelet/soft-serve/internal/tui/style"
1807-	"github.com/charmbracelet/soft-serve/tui/common"
1808-	"github.com/charmbracelet/soft-serve/tui/refs"
1809-	vp "github.com/charmbracelet/soft-serve/tui/viewport"
1810-)
1811-
1812-var (
1813-	diffChroma = &gansi.CodeBlockElement{
1814-		Code:     "",
1815-		Language: "diff",
1816-	}
1817-	waitBeforeLoading = time.Millisecond * 300
1818-)
1819-
1820-type itemsMsg struct{}
1821-
1822-type commitMsg *git.Commit
1823-
1824-type countMsg int64
1825-
1826-type sessionState int
1827-
1828-const (
1829-	logState sessionState = iota
1830-	commitState
1831-	errorState
1832-)
1833-
1834-type item struct {
1835-	*git.Commit
1836-}
1837-
1838-func (i item) Title() string {
1839-	if i.Commit != nil {
1840-		return strings.Split(i.Commit.Message, "\n")[0]
1841-	}
1842-	return ""
1843-}
1844-
1845-func (i item) FilterValue() string { return i.Title() }
1846-
1847-type itemDelegate struct {
1848-	style *style.Styles
1849-}
1850-
1851-func (d itemDelegate) Height() int                               { return 1 }
1852-func (d itemDelegate) Spacing() int                              { return 0 }
1853-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
1854-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
1855-	i, ok := listItem.(item)
1856-	if !ok {
1857-		return
1858-	}
1859-	if i.Commit == nil {
1860-		return
1861-	}
1862-
1863-	hash := i.ID.String()
1864-	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
1865-		d.style.LogItemSelector.GetWidth() +
1866-		d.style.LogItemHash.GetMarginLeft() +
1867-		d.style.LogItemHash.GetWidth() +
1868-		d.style.LogItemInactive.GetMarginLeft()
1869-	title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…")
1870-	if index == m.Index() {
1871-		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
1872-			d.style.LogItemHash.Bold(true).Render(hash[:7])+
1873-			d.style.LogItemActive.Render(title))
1874-	} else {
1875-		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
1876-			d.style.LogItemHash.Render(hash[:7])+
1877-			d.style.LogItemInactive.Render(title))
1878-	}
1879-}
1880-
1881-type Bubble struct {
1882-	repo           common.GitRepo
1883-	count          int64
1884-	list           list.Model
1885-	state          sessionState
1886-	commitViewport *vp.ViewportBubble
1887-	ref            *git.Reference
1888-	style          *style.Styles
1889-	width          int
1890-	widthMargin    int
1891diff --git a/tui/refs/bubble.go b/tui/refs/bubble.go
1892deleted file mode 100644
1893index 7a794c2b6af8fbe1c89f726541459edac60fda2d..0000000000000000000000000000000000000000
1894--- a/tui/refs/bubble.go
1895+++ /dev/null
1896@@ -1,185 +0,0 @@
1897-package refs
1898-
1899-import (
1900-	"fmt"
1901-	"io"
1902-	"sort"
1903-
1904-	"github.com/charmbracelet/bubbles/list"
1905-	tea "github.com/charmbracelet/bubbletea"
1906-	"github.com/charmbracelet/soft-serve/git"
1907-	"github.com/charmbracelet/soft-serve/internal/tui/style"
1908-	"github.com/charmbracelet/soft-serve/tui/common"
1909-)
1910-
1911-type RefMsg = *git.Reference
1912-
1913-type item struct {
1914-	*git.Reference
1915-}
1916-
1917-func (i item) Short() string {
1918-	return i.Reference.Name().Short()
1919-}
1920-
1921-func (i item) FilterValue() string { return i.Short() }
1922-
1923-type items []item
1924-
1925-func (cl items) Len() int      { return len(cl) }
1926-func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1927-func (cl items) Less(i, j int) bool {
1928-	return cl[i].Short() < cl[j].Short()
1929-}
1930-
1931-type itemDelegate struct {
1932-	style *style.Styles
1933-}
1934-
1935-func (d itemDelegate) Height() int                               { return 1 }
1936-func (d itemDelegate) Spacing() int                              { return 0 }
1937-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
1938-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
1939-	s := d.style
1940-	i, ok := listItem.(item)
1941-	if !ok {
1942-		return
1943-	}
1944-
1945-	ref := i.Short()
1946-	if i.Reference.IsTag() {
1947-		ref = s.RefItemTag.Render(ref)
1948-	}
1949-	ref = s.RefItemBranch.Render(ref)
1950-	refMaxWidth := m.Width() -
1951-		s.RefItemSelector.GetMarginLeft() -
1952-		s.RefItemSelector.GetWidth() -
1953-		s.RefItemInactive.GetMarginLeft()
1954-	ref = common.TruncateString(ref, refMaxWidth, "…")
1955-	if index == m.Index() {
1956-		fmt.Fprint(w, s.RefItemSelector.Render(">")+
1957-			s.RefItemActive.Render(ref))
1958-	} else {
1959-		fmt.Fprint(w, s.LogItemSelector.Render(" ")+
1960-			s.RefItemInactive.Render(ref))
1961-	}
1962-}
1963-
1964-type Bubble struct {
1965-	repo         common.GitRepo
1966-	list         list.Model
1967-	style        *style.Styles
1968-	width        int
1969-	widthMargin  int
1970-	height       int
1971-	heightMargin int
1972-	ref          *git.Reference
1973-}
1974-
1975-func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
1976-	head, err := repo.HEAD()
1977-	if err != nil {
1978-		return nil
1979-	}
1980-	l := list.NewModel([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
1981-	l.SetShowFilter(false)
1982-	l.SetShowHelp(false)
1983-	l.SetShowPagination(true)
1984-	l.SetShowStatusBar(false)
1985-	l.SetShowTitle(false)
1986-	l.SetFilteringEnabled(false)
1987-	l.DisableQuitKeybindings()
1988-	b := &Bubble{
1989-		repo:         repo,
1990-		style:        styles,
1991-		width:        width,
1992-		height:       height,
1993-		widthMargin:  widthMargin,
1994-		heightMargin: heightMargin,
1995-		list:         l,
1996diff --git a/tui/tree/bubble.go b/tui/tree/bubble.go
1997deleted file mode 100644
1998index 74a5ccb3c8b84181179ba2322cf2281d649233e4..0000000000000000000000000000000000000000
1999--- a/tui/tree/bubble.go
2000+++ /dev/null
2001@@ -1,341 +0,0 @@
2002-package tree
2003-
2004-import (
2005-	"fmt"
2006-	"io"
2007-	"io/fs"
2008-	"path/filepath"
2009-	"strings"
2010-
2011-	"github.com/charmbracelet/bubbles/list"
2012-	"github.com/charmbracelet/bubbles/viewport"
2013-	tea "github.com/charmbracelet/bubbletea"
2014-	"github.com/charmbracelet/lipgloss"
2015-	"github.com/charmbracelet/soft-serve/git"
2016-	"github.com/charmbracelet/soft-serve/internal/tui/style"
2017-	"github.com/charmbracelet/soft-serve/tui/common"
2018-	"github.com/charmbracelet/soft-serve/tui/refs"
2019-	vp "github.com/charmbracelet/soft-serve/tui/viewport"
2020-	"github.com/dustin/go-humanize"
2021-)
2022-
2023-type fileMsg struct {
2024-	content string
2025-}
2026-
2027-type sessionState int
2028-
2029-const (
2030-	treeState sessionState = iota
2031-	fileState
2032-	errorState
2033-)
2034-
2035-type item struct {
2036-	entry *git.TreeEntry
2037-}
2038-
2039-func (i item) Name() string {
2040-	return i.entry.Name()
2041-}
2042-
2043-func (i item) Mode() fs.FileMode {
2044-	return i.entry.Mode()
2045-}
2046-
2047-func (i item) FilterValue() string { return i.Name() }
2048-
2049-type items []item
2050-
2051-func (cl items) Len() int      { return len(cl) }
2052-func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
2053-func (cl items) Less(i, j int) bool {
2054-	if cl[i].entry.IsTree() && cl[j].entry.IsTree() {
2055-		return cl[i].Name() < cl[j].Name()
2056-	} else if cl[i].entry.IsTree() {
2057-		return true
2058-	} else if cl[j].entry.IsTree() {
2059-		return false
2060-	} else {
2061-		return cl[i].Name() < cl[j].Name()
2062-	}
2063-}
2064-
2065-type itemDelegate struct {
2066-	style *style.Styles
2067-}
2068-
2069-func (d itemDelegate) Height() int                               { return 1 }
2070-func (d itemDelegate) Spacing() int                              { return 0 }
2071-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
2072-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
2073-	s := d.style
2074-	i, ok := listItem.(item)
2075-	if !ok {
2076-		return
2077-	}
2078-
2079-	name := i.Name()
2080-	size := humanize.Bytes(uint64(i.entry.Size()))
2081-	if i.entry.IsTree() {
2082-		size = ""
2083-		name = s.TreeFileDir.Render(name)
2084-	}
2085-	var cs lipgloss.Style
2086-	mode := i.Mode()
2087-	if index == m.Index() {
2088-		cs = s.TreeItemActive
2089-		fmt.Fprint(w, s.TreeItemSelector.Render(">"))
2090-	} else {
2091-		cs = s.TreeItemInactive
2092-		fmt.Fprint(w, s.TreeItemSelector.Render(" "))
2093-	}
2094-	leftMargin := s.TreeItemSelector.GetMarginLeft() +
2095-		s.TreeItemSelector.GetWidth() +
2096-		s.TreeFileMode.GetMarginLeft() +
2097-		s.TreeFileMode.GetWidth() +
2098-		cs.GetMarginLeft()
2099-	rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size)
2100-	name = common.TruncateString(name, m.Width()-leftMargin-rightMargin, "…")
2101diff --git a/tui/viewport/viewport_patch.go b/tui/viewport/viewport_patch.go
2102deleted file mode 100644
2103index 4163b5c40ee90025690f1f4d97d01ac12d310584..0000000000000000000000000000000000000000
2104--- a/tui/viewport/viewport_patch.go
2105+++ /dev/null
2106@@ -1,24 +0,0 @@
2107-package viewport
2108-
2109-import (
2110-	"github.com/charmbracelet/bubbles/viewport"
2111-	tea "github.com/charmbracelet/bubbletea"
2112-)
2113-
2114-type ViewportBubble struct {
2115-	Viewport *viewport.Model
2116-}
2117-
2118-func (v *ViewportBubble) Init() tea.Cmd {
2119-	return nil
2120-}
2121-
2122-func (v *ViewportBubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2123-	vp, cmd := v.Viewport.Update(msg)
2124-	v.Viewport = &vp
2125-	return v, cmd
2126-}
2127-
2128-func (v *ViewportBubble) View() string {
2129-	return v.Viewport.View()
2130-}
2131diff --git a/ui/common/common.go b/ui/common/common.go
2132new file mode 100644
2133index 0000000000000000000000000000000000000000..f18d0298b00422d343f382682c031b15301e02c0
2134--- /dev/null
2135+++ b/ui/common/common.go
2136@@ -0,0 +1,22 @@
2137+package common
2138+
2139+import (
2140+	"github.com/aymanbagabas/go-osc52"
2141+	"github.com/charmbracelet/soft-serve/ui/keymap"
2142+	"github.com/charmbracelet/soft-serve/ui/styles"
2143+)
2144+
2145+// Common is a struct all components should embed.
2146+type Common struct {
2147+	Copy   *osc52.Output
2148+	Styles *styles.Styles
2149+	KeyMap *keymap.KeyMap
2150+	Width  int
2151+	Height int
2152+}
2153+
2154+// SetSize sets the width and height of the common struct.
2155+func (c *Common) SetSize(width, height int) {
2156+	c.Width = width
2157+	c.Height = height
2158+}
2159diff --git a/ui/common/component.go b/ui/common/component.go
2160new file mode 100644
2161index 0000000000000000000000000000000000000000..ed8b9bf05cd39b52086a2a490b045231ac1fd96e
2162--- /dev/null
2163+++ b/ui/common/component.go
2164@@ -0,0 +1,13 @@
2165+package common
2166+
2167+import (
2168+	"github.com/charmbracelet/bubbles/help"
2169+	tea "github.com/charmbracelet/bubbletea"
2170+)
2171+
2172+// Component represents a Bubble Tea model that implements a SetSize function.
2173+type Component interface {
2174+	tea.Model
2175+	help.KeyMap
2176+	SetSize(width, height int)
2177+}
2178diff --git a/ui/common/error.go b/ui/common/error.go
2179new file mode 100644
2180index 0000000000000000000000000000000000000000..fe972980562270ccfb0c8f854e3a3360b4389bc7
2181--- /dev/null
2182+++ b/ui/common/error.go
2183@@ -0,0 +1,13 @@
2184+package common
2185+
2186+import tea "github.com/charmbracelet/bubbletea"
2187+
2188+// ErrorMsg is a Bubble Tea message that represents an error.
2189+type ErrorMsg error
2190+
2191+// ErrorCmd returns an ErrorMsg from error.
2192+func ErrorCmd(err error) tea.Cmd {
2193+	return func() tea.Msg {
2194+		return ErrorMsg(err)
2195+	}
2196+}
2197diff --git a/ui/common/style.go b/ui/common/style.go
2198new file mode 100644
2199index 0000000000000000000000000000000000000000..1e2c911a79fe31f07f144245667a9459d8091ac1
2200--- /dev/null
2201+++ b/ui/common/style.go
2202@@ -0,0 +1,19 @@
2203+package common
2204+
2205+import (
2206+	"github.com/charmbracelet/glamour"
2207+	gansi "github.com/charmbracelet/glamour/ansi"
2208+)
2209+
2210+// StyleConfig returns the default Glamour style configuration.
2211+func StyleConfig() gansi.StyleConfig {
2212+	noColor := ""
2213+	s := glamour.DarkStyleConfig
2214+	s.Document.StylePrimitive.Color = &noColor
2215+	s.CodeBlock.Chroma.Text.Color = &noColor
2216+	s.CodeBlock.Chroma.Name.Color = &noColor
2217+	// This fixes an issue with the default style config. For example
2218+	// highlighting empty spaces with red in Dockerfile type.
2219+	s.CodeBlock.Chroma.Error.BackgroundColor = &noColor
2220+	return s
2221+}
2222diff --git a/ui/common/utils.go b/ui/common/utils.go
2223new file mode 100644
2224index 0000000000000000000000000000000000000000..7c817a50ebcb6b92b04006837bde1bbd3ae732b0
2225--- /dev/null
2226+++ b/ui/common/utils.go
2227@@ -0,0 +1,11 @@
2228+package common
2229+
2230+import "github.com/muesli/reflow/truncate"
2231+
2232+// TruncateString is a convenient wrapper around truncate.TruncateString.
2233+func TruncateString(s string, max int) string {
2234+	if max < 0 {
2235+		max = 0
2236+	}
2237+	return truncate.StringWithTail(s, uint(max), "…")
2238+}
2239diff --git a/ui/components/code/code.go b/ui/components/code/code.go
2240new file mode 100644
2241index 0000000000000000000000000000000000000000..9c832bc836b7f1437a7488b86ca6f55d95af241a
2242--- /dev/null
2243+++ b/ui/components/code/code.go
2244@@ -0,0 +1,259 @@
2245+package code
2246+
2247+import (
2248+	"fmt"
2249+	"strings"
2250+	"sync"
2251+
2252+	"github.com/alecthomas/chroma/lexers"
2253+	tea "github.com/charmbracelet/bubbletea"
2254+	"github.com/charmbracelet/glamour"
2255+	gansi "github.com/charmbracelet/glamour/ansi"
2256+	"github.com/charmbracelet/lipgloss"
2257+	"github.com/charmbracelet/soft-serve/ui/common"
2258+	vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
2259+	"github.com/muesli/termenv"
2260+)
2261+
2262+const (
2263+	tabWidth = 4
2264+)
2265+
2266+var (
2267+	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
2268+	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
2269+)
2270+
2271+// Code is a code snippet.
2272+type Code struct {
2273+	*vp.Viewport
2274+	common         common.Common
2275+	content        string
2276+	extension      string
2277+	renderContext  gansi.RenderContext
2278+	renderMutex    sync.Mutex
2279+	styleConfig    gansi.StyleConfig
2280+	showLineNumber bool
2281+
2282+	NoContentStyle lipgloss.Style
2283+	LineDigitStyle lipgloss.Style
2284+	LineBarStyle   lipgloss.Style
2285+}
2286+
2287+// New returns a new Code.
2288+func New(c common.Common, content, extension string) *Code {
2289+	r := &Code{
2290+		common:         c,
2291+		content:        content,
2292+		extension:      extension,
2293+		Viewport:       vp.New(c),
2294+		NoContentStyle: c.Styles.CodeNoContent.Copy(),
2295+		LineDigitStyle: lineDigitStyle,
2296+		LineBarStyle:   lineBarStyle,
2297+	}
2298+	st := common.StyleConfig()
2299+	r.styleConfig = st
2300+	r.renderContext = gansi.NewRenderContext(gansi.Options{
2301+		ColorProfile: termenv.TrueColor,
2302+		Styles:       st,
2303+	})
2304+	r.SetSize(c.Width, c.Height)
2305+	return r
2306+}
2307+
2308+// SetShowLineNumber sets whether to show line numbers.
2309+func (r *Code) SetShowLineNumber(show bool) {
2310+	r.showLineNumber = show
2311+}
2312+
2313+// SetSize implements common.Component.
2314+func (r *Code) SetSize(width, height int) {
2315+	r.common.SetSize(width, height)
2316+	r.Viewport.SetSize(width, height)
2317+}
2318+
2319+// SetContent sets the content of the Code.
2320+func (r *Code) SetContent(c, ext string) tea.Cmd {
2321+	r.content = c
2322+	r.extension = ext
2323+	return r.Init()
2324+}
2325+
2326+// Init implements tea.Model.
2327+func (r *Code) Init() tea.Cmd {
2328+	w := r.common.Width
2329+	c := r.content
2330+	if c == "" {
2331+		r.Viewport.Model.SetContent(r.NoContentStyle.String())
2332+		return nil
2333+	}
2334+	f, err := r.renderFile(r.extension, c, w)
2335+	if err != nil {
2336+		return common.ErrorCmd(err)
2337+	}
2338+	r.Viewport.Model.SetContent(f)
2339+	return nil
2340+}
2341+
2342+// Update implements tea.Model.
2343+func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2344diff --git a/ui/components/footer/footer.go b/ui/components/footer/footer.go
2345new file mode 100644
2346index 0000000000000000000000000000000000000000..6d724eb230acdb06052b716e4962484f6daf7c3e
2347--- /dev/null
2348+++ b/ui/components/footer/footer.go
2349@@ -0,0 +1,85 @@
2350+package footer
2351+
2352+import (
2353+	"github.com/charmbracelet/bubbles/help"
2354+	"github.com/charmbracelet/bubbles/key"
2355+	tea "github.com/charmbracelet/bubbletea"
2356+	"github.com/charmbracelet/lipgloss"
2357+	"github.com/charmbracelet/soft-serve/ui/common"
2358+)
2359+
2360+// Footer is a Bubble Tea model that displays help and other info.
2361+type Footer struct {
2362+	common common.Common
2363+	help   help.Model
2364+	keymap help.KeyMap
2365+}
2366+
2367+// New creates a new Footer.
2368+func New(c common.Common, keymap help.KeyMap) *Footer {
2369+	h := help.New()
2370+	h.Styles.ShortKey = c.Styles.HelpKey
2371+	h.Styles.ShortDesc = c.Styles.HelpValue
2372+	h.Styles.FullKey = c.Styles.HelpKey
2373+	h.Styles.FullDesc = c.Styles.HelpValue
2374+	f := &Footer{
2375+		common: c,
2376+		help:   h,
2377+		keymap: keymap,
2378+	}
2379+	f.SetSize(c.Width, c.Height)
2380+	return f
2381+}
2382+
2383+// SetSize implements common.Component.
2384+func (f *Footer) SetSize(width, height int) {
2385+	f.common.SetSize(width, height)
2386+	f.help.Width = width -
2387+		f.common.Styles.Footer.GetHorizontalFrameSize()
2388+}
2389+
2390+// Init implements tea.Model.
2391+func (f *Footer) Init() tea.Cmd {
2392+	return nil
2393+}
2394+
2395+// Update implements tea.Model.
2396+func (f *Footer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2397+	return f, nil
2398+}
2399+
2400+// View implements tea.Model.
2401+func (f *Footer) View() string {
2402+	if f.keymap == nil {
2403+		return ""
2404+	}
2405+	s := f.common.Styles.Footer.Copy().
2406+		Width(f.common.Width)
2407+	helpView := f.help.View(f.keymap)
2408+	return s.Render(helpView)
2409+}
2410+
2411+// ShortHelp returns the short help key bindings.
2412+func (f *Footer) ShortHelp() []key.Binding {
2413+	return f.keymap.ShortHelp()
2414+}
2415+
2416+// FullHelp returns the full help key bindings.
2417+func (f *Footer) FullHelp() [][]key.Binding {
2418+	return f.keymap.FullHelp()
2419+}
2420+
2421+// ShowAll returns whether the full help is shown.
2422+func (f *Footer) ShowAll() bool {
2423+	return f.help.ShowAll
2424+}
2425+
2426+// SetShowAll sets whether the full help is shown.
2427+func (f *Footer) SetShowAll(show bool) {
2428+	f.help.ShowAll = show
2429+}
2430+
2431+// Height returns the height of the footer.
2432+func (f *Footer) Height() int {
2433+	return lipgloss.Height(f.View())
2434+}
2435diff --git a/ui/components/header/header.go b/ui/components/header/header.go
2436new file mode 100644
2437index 0000000000000000000000000000000000000000..b490ab1e3f64aac66777a2ec48bf79df10a1c822
2438--- /dev/null
2439+++ b/ui/components/header/header.go
2440@@ -0,0 +1,44 @@
2441+package header
2442+
2443+import (
2444+	"strings"
2445+
2446+	tea "github.com/charmbracelet/bubbletea"
2447+	"github.com/charmbracelet/soft-serve/ui/common"
2448+)
2449+
2450+// Header represents a header component.
2451+type Header struct {
2452+	common common.Common
2453+	text   string
2454+}
2455+
2456+// New creates a new header component.
2457+func New(c common.Common, text string) *Header {
2458+	h := &Header{
2459+		common: c,
2460+		text:   text,
2461+	}
2462+	return h
2463+}
2464+
2465+// SetSize implements common.Component.
2466+func (h *Header) SetSize(width, height int) {
2467+	h.common.SetSize(width, height)
2468+}
2469+
2470+// Init implements tea.Model.
2471+func (h *Header) Init() tea.Cmd {
2472+	return nil
2473+}
2474+
2475+// Update implements tea.Model.
2476+func (h *Header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2477+	return h, nil
2478+}
2479+
2480+// View implements tea.Model.
2481+func (h *Header) View() string {
2482+	s := h.common.Styles.Header.Copy().Width(h.common.Width)
2483+	return s.Render(strings.TrimSpace(h.text))
2484+}
2485diff --git a/ui/components/selector/selector.go b/ui/components/selector/selector.go
2486new file mode 100644
2487index 0000000000000000000000000000000000000000..ed044990e6d76473d015bb7b2c93b5352408bc02
2488--- /dev/null
2489+++ b/ui/components/selector/selector.go
2490@@ -0,0 +1,222 @@
2491+package selector
2492+
2493+import (
2494+	"github.com/charmbracelet/bubbles/key"
2495+	"github.com/charmbracelet/bubbles/list"
2496+	tea "github.com/charmbracelet/bubbletea"
2497+	"github.com/charmbracelet/soft-serve/ui/common"
2498+)
2499+
2500+// Selector is a list of items that can be selected.
2501+type Selector struct {
2502+	list.Model
2503+	common      common.Common
2504+	active      int
2505+	filterState list.FilterState
2506+}
2507+
2508+// IdentifiableItem is an item that can be identified by a string. Implements list.DefaultItem.
2509+type IdentifiableItem interface {
2510+	list.DefaultItem
2511+	ID() string
2512+}
2513+
2514+// ItemDelegate is a wrapper around list.ItemDelegate.
2515+type ItemDelegate interface {
2516+	list.ItemDelegate
2517+}
2518+
2519+// SelectMsg is a message that is sent when an item is selected.
2520+type SelectMsg struct{ IdentifiableItem }
2521+
2522+// ActiveMsg is a message that is sent when an item is active but not selected.
2523+type ActiveMsg struct{ IdentifiableItem }
2524+
2525+// New creates a new selector.
2526+func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector {
2527+	itms := make([]list.Item, len(items))
2528+	for i, item := range items {
2529+		itms[i] = item
2530+	}
2531+	l := list.New(itms, delegate, common.Width, common.Height)
2532+	s := &Selector{
2533+		Model:  l,
2534+		common: common,
2535+	}
2536+	s.SetSize(common.Width, common.Height)
2537+	return s
2538+}
2539+
2540+// PerPage returns the number of items per page.
2541+func (s *Selector) PerPage() int {
2542+	return s.Model.Paginator.PerPage
2543+}
2544+
2545+// SetPage sets the current page.
2546+func (s *Selector) SetPage(page int) {
2547+	s.Model.Paginator.Page = page
2548+}
2549+
2550+// Page returns the current page.
2551+func (s *Selector) Page() int {
2552+	return s.Model.Paginator.Page
2553+}
2554+
2555+// TotalPages returns the total number of pages.
2556+func (s *Selector) TotalPages() int {
2557+	return s.Model.Paginator.TotalPages
2558+}
2559+
2560+// Select selects the item at the given index.
2561+func (s *Selector) Select(index int) {
2562+	s.Model.Select(index)
2563+}
2564+
2565+// SetShowTitle sets the show title flag.
2566+func (s *Selector) SetShowTitle(show bool) {
2567+	s.Model.SetShowTitle(show)
2568+}
2569+
2570+// SetShowHelp sets the show help flag.
2571+func (s *Selector) SetShowHelp(show bool) {
2572+	s.Model.SetShowHelp(show)
2573+}
2574+
2575+// SetShowStatusBar sets the show status bar flag.
2576+func (s *Selector) SetShowStatusBar(show bool) {
2577+	s.Model.SetShowStatusBar(show)
2578+}
2579+
2580+// DisableQuitKeybindings disables the quit keybindings.
2581+func (s *Selector) DisableQuitKeybindings() {
2582+	s.Model.DisableQuitKeybindings()
2583+}
2584+
2585+// SetShowFilter sets the show filter flag.
2586+func (s *Selector) SetShowFilter(show bool) {
2587+	s.Model.SetShowFilter(show)
2588+}
2589+