Repository: charmbracelet/bubbletea
Pull request #122 (2021-08-16 19:37:49)

Find a way to talk about Zeichenorientierte Benutzerschnittstelle


Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..c622d76
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,42 @@
+version: 2
+updates:
+  - package-ecosystem: "gomod"
+    directory: "/"
+    schedule:
+      interval: "daily"
+      time: "08:00"
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "feat"
+      include: "scope"
+  - package-ecosystem: "gomod"
+    directory: "/examples"
+    schedule:
+      interval: "daily"
+      time: "08:00"
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "chore"
+      include: "scope"
+  - package-ecosystem: "gomod"
+    directory: "/tutorials"
+    schedule:
+      interval: "daily"
+      time: "08:00"
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "chore"
+      include: "scope"
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "daily"
+      time: "08:00"
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "chore"
+      include: "scope"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2595fcd..5d086b2 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,19 +5,19 @@ jobs:
   build:
     strategy:
       matrix:
-        go-version: [~1.13, ^1]
+        go-version: [~1.16, ^1]
         os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
     env:
       GO111MODULE: "on"
     steps:
       - name: Install Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ matrix.go-version }}
 
       - name: Checkout code
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - name: Download Go modules
         run: go mod download
@@ -25,6 +25,9 @@ jobs:
       - name: Build
         run: go build -v ./...
 
+      - name: Test
+        run: go test ./...
+
       - name: Build examples
         run: go build -v ./...
         working-directory: ./examples
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 01c10d2..bcc6721 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -12,12 +12,12 @@ jobs:
       GO111MODULE: "on"
     steps:
       - name: Install Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ matrix.go-version }}
 
       - name: Checkout code
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - name: Coverage
         env:
diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml
new file mode 100644
index 0000000..77348b9
--- /dev/null
+++ b/.github/workflows/lint-soft.yml
@@ -0,0 +1,28 @@
+name: lint-soft
+on:
+  push:
+  pull_request:
+
+permissions:
+  contents: read
+  # Optional: allow read access to pull request. Use with `only-new-issues` option.
+  pull-requests: read
+
+jobs:
+  golangci:
+    name: lint-soft
+    runs-on: ubuntu-latest
+    steps:
+      - name: Install Go
+        uses: actions/setup-go@v3
+        with:
+          go-version: ^1
+
+      - uses: actions/checkout@v3
+      - name: golangci-lint
+        uses: golangci/golangci-lint-action@v3
+        with:
+          # Optional: golangci-lint command line arguments.
+          args: --config .golangci-soft.yml --issues-exit-code=0
+          # Optional: show only new issues if it's a pull request. The default value is `false`.
+          only-new-issues: true
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 8f5e197..1129ece 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,18 +1,28 @@
 name: lint
-on: [push, pull_request]
+on:
+  push:
+  pull_request:
+
+permissions:
+  contents: read
+  # Optional: allow read access to pull request. Use with `only-new-issues` option.
+  pull-requests: read
 
 jobs:
   golangci:
     name: lint
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
+      - name: Install Go
+        uses: actions/setup-go@v3
+        with:
+          go-version: ^1
+
+      - uses: actions/checkout@v3
       - name: golangci-lint
-        uses: golangci/golangci-lint-action@v2
+        uses: golangci/golangci-lint-action@v3
         with:
           # Optional: golangci-lint command line arguments.
-          args: --issues-exit-code=0
-          # Optional: working directory, useful for monorepos
-          # working-directory: somedir
+          #args:
           # Optional: show only new issues if it's a pull request. The default value is `false`.
           only-new-issues: true
diff --git a/.github/workflows/soft-serve.yml b/.github/workflows/soft-serve.yml
new file mode 100644
index 0000000..8eb3221
--- /dev/null
+++ b/.github/workflows/soft-serve.yml
@@ -0,0 +1,12 @@
+name: soft-serve
+
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+  soft-serve:
+    uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main
+    secrets:
+      ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}"
diff --git a/.gitignore b/.gitignore
index 8de6c13..0eac410 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,11 @@
 .envrc
 
 examples/fullscreen/fullscreen
+examples/help/help
 examples/http/http
+examples/list-default/list-default
+examples/list-fancy/list-fancy
+examples/list-simple/list-simple
 examples/mouse/mouse
 examples/pager/pager
 examples/simple/simple
@@ -13,3 +17,4 @@ examples/views/views
 tutorials/basics/basics
 tutorials/commands/commands
 .idea
+coverage.txt
diff --git a/.golangci-soft.yml b/.golangci-soft.yml
new file mode 100644
index 0000000..ef456e0
--- /dev/null
+++ b/.golangci-soft.yml
@@ -0,0 +1,47 @@
+run:
+  tests: false
+
+issues:
+  include:
+    - EXC0001
+    - EXC0005
+    - EXC0011
+    - EXC0012
+    - EXC0013
+
+  max-issues-per-linter: 0
+  max-same-issues: 0
+
+linters:
+  enable:
+    # - dupl
+    - exhaustive
+    # - exhaustivestruct
+    - goconst
+    - godot
+    - godox
+    - gomnd
+    - gomoddirectives
+    - goprintffuncname
+    - ifshort
+    # - lll
+    - misspell
+    - nakedret
+    - nestif
+    - noctx
+    - nolintlint
+    - prealloc
+    - wrapcheck
+
+  # disable default linters, they are already enabled in .golangci.yml
+  disable:
+    - deadcode
+    - errcheck
+    - gosimple
+    - govet
+    - ineffassign
+    - staticcheck
+    - structcheck
+    - typecheck
+    - unused
+    - varcheck
diff --git a/.golangci.yml b/.golangci.yml
index 85cfb6d..a5a91d0 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -15,20 +15,15 @@ issues:
 linters:
   enable:
     - bodyclose
-    - dupl
     - exportloopref
-    - goconst
-    - godot
-    - godox
     - goimports
-    - goprintffuncname
     - gosec
-    - ifshort
-    - misspell
-    - prealloc
+    - nilerr
+    - predeclared
     - revive
     - rowserrcheck
     - sqlclosecheck
+    - tparallel
     - unconvert
     - unparam
     - whitespace
diff --git a/README.md b/README.md
index e1eb787..9f437f6 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and
 complex terminal applications, either inline, full-window, or a mix of both.
 
 <p>
-    <img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif" width="800" alt="Bubble Tea Example">
+    <img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0" width="800" alt="Bubble Tea Example">
 </p>
 
 Bubble Tea is in use in production and includes a number of features and
@@ -38,7 +38,7 @@ Be sure to check out [Bubbles][bubbles], a library of common UI components for B
 ## Tutorial
 
 Bubble Tea is based on the functional design paradigms of [The Elm
-Architecture][elm] which happens work nicely with Go. It's a delightful way to
+Architecture][elm], which happen to work nicely with Go. It's a delightful way to
 build applications.
 
 By the way, the non-annotated source code for this program is available
@@ -50,7 +50,7 @@ This tutorial assumes you have a working knowledge of Go.
 
 ## Enough! Let's get to it.
 
-For this tutorial we're making a to-do list.
+For this tutorial we're making a shopping list.
 
 To start we'll define our package and import some libraries. Our only external
 import will be the Bubble Tea library, which we'll call `tea` for short.
@@ -88,23 +88,29 @@ type model struct {
 
 ## Initialization
 
-Next we'll define our application’s initial state. We’ll store our initial
-model in a simple variable, and then define the `Init` method.  `Init` can
-return a `Cmd` that could perform some initial I/O. For now, we don't need to
-do any I/O, so for the command we'll just return `nil`, which translates to "no
-command."
+Next we’ll define our application’s initial state. In this case we’re defining
+a function to return our initial model, however we could just as easily define
+the initial model as a variable elsewhere, too.
 
 ```go
-var initialModel = model{
-    // Our to-do list is just a grocery list
-    choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
-
-    // A map which indicates which choices are selected. We're using
-    // the  map like a mathematical set. The keys refer to the indexes
-    // of the `choices` slice, above.
-    selected: make(map[int]struct{}),
+func initialModel() model {
+	return model{
+		// Our shopping list is a grocery list
+		choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
+
+		// A map which indicates which choices are selected. We're using
+		// the  map like a mathematical set. The keys refer to the indexes
+		// of the `choices` slice, above.
+		selected: make(map[int]struct{}),
+	}
 }
+```
+
+Next we define the `Init` method. `Init` can return a `Cmd` that could perform
+some initial I/O. For now, we don't need to do any I/O, so for the command
+we'll just return `nil`, which translates to "no command."
 
+```go
 func (m model) Init() tea.Cmd {
     // Just return `nil`, which means "no I/O right now, please."
     return nil
@@ -113,15 +119,15 @@ func (m model) Init() tea.Cmd {
 
 ## The Update Method
 
-Next we'll define the update method. The update function is called when
-"things happen." Its job is to look at what has happened and return an updated
-model in response to whatever happened. It can also return a `Cmd` and make
-more things happen, but for now don't worry about that part.
+Next up is the update method. The update function is called when ”things
+happen.” Its job is to look at what has happened and return an updated model in
+response. It can also return a `Cmd` to make more things happen, but for now
+don't worry about that part.
 
-In our case, when a user presses the down arrow, `update`'s job is to notice
+In our case, when a user presses the down arrow, `Update`’s job is to notice
 that the down arrow was pressed and move the cursor accordingly (or not).
 
-The "something happened" comes in the form of a `Msg`, which can be any type.
+The “something happened” comes in the form of a `Msg`, which can be any type.
 Messages are the result of some I/O that took place, such as a keypress, timer
 tick, or a response from a server.
 
@@ -175,18 +181,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 ```
 
-You may have noticed that "ctrl+c" and "q" above return a `tea.Quit` command
-with the model. That's a special command which instructs the Bubble Tea runtime
-to quit, exiting the program.
+You may have noticed that <kbd>ctrl+c</kbd> and <kbd>q</kbd> above return
+a `tea.Quit` command with the model. That’s a special command which instructs
+the Bubble Tea runtime to quit, exiting the program.
 
 ## The View Method
 
-At last, it's time to render our UI. Of all the methods, the view is the
+At last, it’s time to render our UI. Of all the methods, the view is the
 simplest. We look at the  model in it's current state and use it to return
 a `string`.  That string is our UI!
 
-Because the view describes the entire UI of your application, you don't have
-to worry about redraw logic and stuff like that. Bubble Tea takes care of it
+Because the view describes the entire UI of your application, you don’t have to
+worry about redrawing logic and stuff like that. Bubble Tea takes care of it
 for you.
 
 ```go
@@ -228,7 +234,7 @@ The last step is to simply run our program. We pass our initial model to
 
 ```go
 func main() {
-    p := tea.NewProgram(initialModel)
+    p := tea.NewProgram(initialModel())
     if err := p.Start(); err != nil {
         fmt.Printf("Alas, there's been an error: %v", err)
         os.Exit(1)
@@ -236,7 +242,7 @@ func main() {
 }
 ```
 
-## What's Next?
+## What’s Next?
 
 This tutorial covers the basics of building an interactive terminal UI, but
 in the real world you'll also need to perform I/O. To learn about that have a
@@ -249,6 +255,23 @@ there are [Go Docs][docs].
 [examples]: http://github.com/charmbracelet/bubbletea/tree/master/examples
 [docs]: https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc
 
+## Debugging with Delve
+
+Since Bubble Tea apps assume control of stdin and stdout, you’ll need to run
+delve in headless mode and then connect to it:
+
+```bash
+# Start the debugger
+$ dlv debug --headless .
+API server listening at: 127.0.0.1:34241
+
+# Connect to it from another terminal
+$ dlv connect 127.0.0.1:34241
+```
+
+Note that the default port used will vary on your system and per run, so
+actually watch out what address the first `dlv` run tells you to connect to.
+
 ## Libraries we use with Bubble Tea
 
 * [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on
@@ -267,23 +290,40 @@ there are [Go Docs][docs].
 
 For some Bubble Tea programs in production, see:
 
-* [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser and online markdown stash
+* [AT CLI](https://github.com/daskycodes/at_cli): a utility for executing AT Commands via serial port connections
+* [Canard](https://github.com/mrusme/canard): an RSS client
 * [The Charm Tool](https://github.com/charmbracelet/charm): the Charm user account manager
-* [kboard](https://github.com/CamiloGarciaLaRotta/kboard): a typing game
-* [tasktimer](https://github.com/caarlos0/tasktimer): a dead-simple task timer
+* [clidle](https://github.com/ajeetdsouza/clidle): a Wordle clone for your terminal
+* [fm](https://github.com/knipferrc/fm): a terminal-based file manager
 * [fork-cleaner](https://github.com/caarlos0/fork-cleaner): cleans up old and inactive forks in your GitHub account
-* [STTG](https://github.com/wille1101/sttg): teletext client for SVT, Sweden’s national public television station
+* [gambit](https://github.com/maaslalani/gambit): play chess in the terminal
+* [gembro](https://git.sr.ht/~rafael/gembro): a mouse-driven Gemini browser
+* [gh-b](https://github.com/joaom00/gh-b): GitHub CLI extension to easily manage your branches
+* [gh-dash](https://www.github.com/dlvhdr/gh-dash): GitHub cli extension to display a dashboard of PRs and issues
 * [gitflow-toolkit](https://github.com/mritd/gitflow-toolkit): a GitFlow submission tool
-* [ticker](https://github.com/achannarasappa/ticker): a terminal stock watcher and stock position tracker
-* [tz](https://github.com/oz/tz): an aid for scheduling across multiple time zones
+* [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser and online markdown stash
+* [gocovsh](https://github.com/orlangure/gocovsh): explore Go coverage reports from the CLI
 * [httpit](https://github.com/gonetx/httpit): a rapid http(s) benchmark tool
-* [gembro](https://git.sr.ht/~rafael/gembro): a mouse-driven Gemini browser
-* [fm](https://github.com/knipferrc/fm): a terminal-based file manager
-* [StormForge Optimize – Controller](https://github.com/thestormforge/optimize-controller): a tool for experimenting with application configurations in Kubernetes
+* [IDNT](https://github.com/r-darwish/idnt): batch software uninstaller
+* [kboard](https://github.com/CamiloGarciaLaRotta/kboard): a typing game
+* [mergestat](https://github.com/mergestat/mergestat): run SQL queries on git repositories
+* [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client
+* [portal][portal]: securely send transfer between computers
+* [redis-viewer](https://github.com/SaltFishPr/redis-viewer): browse Redis databases
 * [Slides](https://github.com/maaslalani/slides): a markdown-based presentation tool
+* [Soft Serve](https://github.com/charmbracelet/soft-serve): a command-line-first Git server that runs a TUI over SSH
+* [StormForge Optimize Controller](https://github.com/thestormforge/optimize-controller): a tool for experimenting with application configurations in Kubernetes
+* [STTG](https://github.com/wille1101/sttg): teletext client for SVT, Sweden’s national public television station
+* [sttr](https://github.com/abhimanyu003/sttr): run various text transformations
+* [tasktimer](https://github.com/caarlos0/tasktimer): a dead-simple task timer
+* [termdbms](https://github.com/mathaou/termdbms): a keyboard and mouse driven database browser
+* [ticker](https://github.com/achannarasappa/ticker): a terminal stock watcher and stock position tracker
+* [tran](https://github.com/abdfnx/tran): securely transfer stuff between computers (based on [portal][portal])
+* [tz](https://github.com/oz/tz): an aid for scheduling across multiple time zones
 * [Typer](https://github.com/maaslalani/typer): a typing test
-* [AT CLI](https://github.com/daskycodes/at_cli): a utility to for executing AT Commands via serial port connections
-* [Canard](https://github.com/mrusme/canard): an RSS client
+* [wishlist](https://github.com/charmbracelet/wishlist): an SSH directory
+
+[portal]: https://github.com/ZinoKader/portal
 
 ## Feedback
 
@@ -295,21 +335,22 @@ We'd love to hear your thoughts on this tutorial. Feel free to drop us a note!
 ## Acknowledgments
 
 Bubble Tea is based on the paradigms of [The Elm Architecture][elm] by Evan
-Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk.
+Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk. It’s
+inspired by the many great [_Zeichenorientierte Benutzerschnittstellen_][zb]
+of days past.
 
 [elm]: https://guide.elm-lang.org/architecture/
 [gotea]: https://github.com/tj/go-tea
-
+[zb]: https://de.wikipedia.org/wiki/Zeichenorientierte_Benutzerschnittstelle
 
 ## License
 
 [MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE)
 
-
 ***
 
 Part of [Charm](https://charm.sh).
 
-<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge-unrounded.jpg" width="400"></a>
+<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
 
 Charm热爱开源 • Charm loves open source
diff --git a/commands.go b/commands.go
index 5a4d3f0..99007b2 100644
--- a/commands.go
+++ b/commands.go
@@ -24,6 +24,35 @@ import (
 //   cmd := Every(time.Second, func(t time.Time) Msg {
 //      return TickMsg(t)
 //   })
+//
+// Beginners' note: Every sends a single message and won't automatically
+// dispatch messages at an interval. To do that, you'll want to return another
+// Every command after receiving your tick message. For example:
+//
+//   type TickMsg time.Time
+//
+//   // Send a message every second.
+//   func tickEvery() Cmd {
+//       return Every(time.Second, func(t time.Time) Msg {
+//           return TickMsg(t)
+//       })
+//   }
+//
+//   func (m model) Init() Cmd {
+//       // Start ticking.
+//       return tickEvery()
+//   }
+//
+//   func (m model) Update(msg Msg) (Model, Cmd) {
+//       switch msg.(type) {
+//       case TickMsg:
+//           // Return your Every command again to loop.
+//           return m, tickEvery()
+//       }
+//       return m, nil
+//   }
+//
+// Every is analogous to Tick in the Elm Architecture.
 func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
 	return func() Msg {
 		n := time.Now()
@@ -45,6 +74,33 @@ func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
 //   cmd := Tick(time.Second, func(t time.Time) Msg {
 //      return TickMsg(t)
 //   })
+//
+// Beginners' note: Tick sends a single message and won't automatically
+// dispatch messages at an interval. To do that, you'll want to return another
+// Tick command after receiving your tick message. For example:
+//
+//   type TickMsg time.Time
+//
+//   func doTick() Cmd {
+//       return Tick(time.Second, func(t time.Time) Msg {
+//           return TickMsg(t)
+//       })
+//   }
+//
+//   func (m model) Init() Cmd {
+//       // Start ticking.
+//       return doTick()
+//   }
+//
+//   func (m model) Update(msg Msg) (Model, Cmd) {
+//       switch msg.(type) {
+//       case TickMsg:
+//           // Return your Tick command again to loop.
+//           return m, doTick()
+//       }
+//       return m, nil
+//   }
+//
 func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
 	return func() Msg {
 		t := time.NewTimer(d)
@@ -57,10 +113,10 @@ func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
 // The Msg returned is the first non-nil message returned by a Cmd.
 //
 //   func saveStateCmd() Msg {
-//   	if err := save(); err != nil {
-//    		return errMsg{err}
-//    	}
-//   	return nil
+//      if err := save(); err != nil {
+//          return errMsg{err}
+//      }
+//      return nil
 //   }
 //
 //   cmd := Sequentially(saveStateCmd, Quit)
@@ -68,6 +124,9 @@ func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
 func Sequentially(cmds ...Cmd) Cmd {
 	return func() Msg {
 		for _, cmd := range cmds {
+			if cmd == nil {
+				continue
+			}
 			if msg := cmd(); msg != nil {
 				return msg
 			}
diff --git a/examples/altscreen-toggle/main.go b/examples/altscreen-toggle/main.go
index c63cee2..539ee52 100644
--- a/examples/altscreen-toggle/main.go
+++ b/examples/altscreen-toggle/main.go
@@ -9,7 +9,7 @@ import (
 )
 
 var (
-	color   = termenv.ColorProfile().Color
+	color   = termenv.EnvColorProfile().Color
 	keyword = termenv.Style{}.Foreground(color("204")).Background(color("235")).Styled
 	help    = termenv.Style{}.Foreground(color("241")).Styled
 )
diff --git a/examples/chat/main.go b/examples/chat/main.go
new file mode 100644
index 0000000..99fd0c6
--- /dev/null
+++ b/examples/chat/main.go
@@ -0,0 +1,110 @@
+package main
+
+// A simple program demonstrating the text area component from the Bubbles
+// component library.
+
+import (
+	"fmt"
+	"log"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/textarea"
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+func main() {
+	p := tea.NewProgram(initialModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+type tickMsg struct{}
+type errMsg error
+
+type model struct {
+	viewport    viewport.Model
+	messages    []string
+	textarea    textarea.Model
+	senderStyle lipgloss.Style
+	err         error
+}
+
+func initialModel() model {
+	ta := textarea.New()
+	ta.Placeholder = "Send a message..."
+	ta.Focus()
+
+	ta.Prompt = "┃ "
+	ta.CharLimit = 280
+
+	ta.SetWidth(30)
+	ta.SetHeight(3)
+
+	// Remove cursor line styling
+	ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
+
+	ta.ShowLineNumbers = false
+
+	vp := viewport.New(30, 10)
+	vp.SetContent(`Welcome to the Bubbles multi-line text input!
+Try typing any message and pressing ENTER.
+If you write a long message, it will automatically wrap :D
+	`)
+
+	ta.KeyMap.InsertNewline.SetEnabled(false)
+
+	return model{
+		textarea:    ta,
+		messages:    []string{},
+		viewport:    vp,
+		senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")),
+		err:         nil,
+	}
+}
+
+func (m model) Init() tea.Cmd {
+	return textarea.Blink
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var (
+		tiCmd tea.Cmd
+		vpCmd tea.Cmd
+	)
+
+	m.textarea, tiCmd = m.textarea.Update(msg)
+	m.viewport, vpCmd = m.viewport.Update(msg)
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.Type {
+		case tea.KeyCtrlC, tea.KeyEsc:
+			fmt.Println(m.textarea.Value())
+			return m, tea.Quit
+		case tea.KeyEnter:
+			m.messages = append(m.messages, m.senderStyle.Render("You: ")+m.textarea.Value())
+			m.viewport.SetContent(strings.Join(m.messages, "\n"))
+			m.textarea.Reset()
+			m.viewport.GotoBottom()
+		}
+
+	// We handle errors just like any other message
+	case errMsg:
+		m.err = msg
+		return m, nil
+	}
+
+	return m, tea.Batch(tiCmd, vpCmd)
+}
+
+func (m model) View() string {
+	return fmt.Sprintf(
+		"%s\n\n%s",
+		m.viewport.View(),
+		m.textarea.View(),
+	) + "\n\n"
+}
diff --git a/examples/countdown/main.go b/examples/countdown/main.go
deleted file mode 100644
index 5bb33b2..0000000
--- a/examples/countdown/main.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"time"
-
-	tea "github.com/charmbracelet/bubbletea"
-)
-
-var (
-	duration = time.Second * 10
-	interval = time.Millisecond
-)
-
-func main() {
-	m := model{
-		timeout: time.Now().Add(duration),
-	}
-
-	if err := tea.NewProgram(m).Start(); err != nil {
-		fmt.Println("Oh no, it didn't work:", err)
-		os.Exit(1)
-	}
-}
-
-type tickMsg time.Time
-
-type model struct {
-	timeout  time.Time
-	lastTick time.Time
-}
-
-func (m model) Init() tea.Cmd {
-	return tick()
-}
-
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "ctrl+c", "q", "esc":
-			return m, tea.Quit
-		}
-
-	case tickMsg:
-		t := time.Time(msg)
-		if t.After(m.timeout) {
-			return m, tea.Quit
-		}
-		m.lastTick = t
-		return m, tick()
-	}
-
-	return m, nil
-}
-
-func (m model) View() string {
-	t := m.timeout.Sub(m.lastTick).Milliseconds()
-	secs := t / 1000
-	millis := t % 1000 / 10
-	return fmt.Sprintf("This program will quit in %02d:%02d\n", secs, millis)
-}
-
-func tick() tea.Cmd {
-	return tea.Tick(time.Duration(interval), func(t time.Time) tea.Msg {
-		return tickMsg(t)
-	})
-}
diff --git a/examples/credit-card-form/main.go b/examples/credit-card-form/main.go
new file mode 100644
index 0000000..f4a9293
--- /dev/null
+++ b/examples/credit-card-form/main.go
@@ -0,0 +1,203 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"strconv"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/textinput"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+func main() {
+	p := tea.NewProgram(initialModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+type tickMsg struct{}
+type errMsg error
+
+const (
+	ccn = iota
+	exp
+	cvv
+)
+
+const (
+	hotPink  = lipgloss.Color("#FF06B7")
+	darkGray = lipgloss.Color("#767676")
+)
+
+var (
+	inputStyle    = lipgloss.NewStyle().Foreground(hotPink)
+	continueStyle = lipgloss.NewStyle().Foreground(darkGray)
+)
+
+type model struct {
+	inputs  []textinput.Model
+	focused int
+	err     error
+}
+
+// Validator functions to ensure valid input
+func ccnValidator(s string) error {
+	// Credit Card Number should a string less than 20 digits
+	// It should include 16 integers and 3 spaces
+	if len(s) > 16+3 {
+		return fmt.Errorf("CCN is too long")
+	}
+
+	// The last digit should be a number unless it is a multiple of 4 in which
+	// case it should be a space
+	if len(s)%5 == 0 && s[len(s)-1] != ' ' {
+		return fmt.Errorf("CCN must separate groups with spaces")
+	}
+	if len(s)%5 != 0 && (s[len(s)-1] < '0' || s[len(s)-1] > '9') {
+		return fmt.Errorf("CCN is invalid")
+	}
+
+	// The remaining digits should be integers
+	c := strings.ReplaceAll(s, " ", "")
+	_, err := strconv.ParseInt(c, 10, 64)
+
+	return err
+}
+
+func expValidator(s string) error {
+	// The 3 character should be a slash (/)
+	// The rest thould be numbers
+	e := strings.ReplaceAll(s, "/", "")
+	_, err := strconv.ParseInt(e, 10, 64)
+	if err != nil {
+		return fmt.Errorf("EXP is invalid")
+	}
+
+	// There should be only one slash and it should be in the 2nd index (3rd character)
+	if len(s) >= 3 && (strings.Index(s, "/") != 2 || strings.LastIndex(s, "/") != 2) {
+		return fmt.Errorf("EXP is invalid")
+	}
+
+	return nil
+}
+
+func cvvValidator(s string) error {
+	// The CVV should be a number of 3 digits
+	// Since the input will already ensure that the CVV is a string of length 3,
+	// All we need to do is check that it is a number
+	_, err := strconv.ParseInt(s, 10, 64)
+	return err
+}
+
+func initialModel() model {
+	var inputs []textinput.Model = make([]textinput.Model, 3)
+	inputs[ccn] = textinput.New()
+	inputs[ccn].Placeholder = "4505 **** **** 1234"
+	inputs[ccn].Focus()
+	inputs[ccn].CharLimit = 20
+	inputs[ccn].Width = 30
+	inputs[ccn].Prompt = ""
+	inputs[ccn].Validate = ccnValidator
+
+	inputs[exp] = textinput.New()
+	inputs[exp].Placeholder = "MM/YY "
+	inputs[exp].CharLimit = 5
+	inputs[exp].Width = 5
+	inputs[exp].Prompt = ""
+	inputs[exp].Validate = expValidator
+
+	inputs[cvv] = textinput.New()
+	inputs[cvv].Placeholder = "XXX"
+	inputs[cvv].CharLimit = 3
+	inputs[cvv].Width = 5
+	inputs[cvv].Prompt = ""
+	inputs[cvv].Validate = cvvValidator
+
+	return model{
+		inputs:  inputs,
+		focused: 0,
+		err:     nil,
+	}
+}
+
+func (m model) Init() tea.Cmd {
+	return textinput.Blink
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var (
+		cmds []tea.Cmd = make([]tea.Cmd, len(m.inputs))
+	)
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.Type {
+		case tea.KeyEnter:
+			if m.focused == len(m.inputs)-1 {
+				return m, tea.Quit
+			} else {
+				m.nextInput()
+			}
+		case tea.KeyCtrlC, tea.KeyEsc:
+			return m, tea.Quit
+		case tea.KeyShiftTab, tea.KeyCtrlP:
+			m.prevInput()
+		case tea.KeyTab, tea.KeyCtrlN:
+			m.nextInput()
+		}
+		for i := range m.inputs {
+			m.inputs[i].Blur()
+		}
+		m.inputs[m.focused].Focus()
+
+	// We handle errors just like any other message
+	case errMsg:
+		m.err = msg
+		return m, nil
+	}
+
+	for i := range m.inputs {
+		m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
+	}
+	return m, tea.Batch(cmds...)
+}
+
+func (m model) View() string {
+	return fmt.Sprintf(
+		` Total: $21.50:
+
+ %s
+ %s
+ 
+ %s  %s
+ %s  %s
+
+ %s
+`,
+		inputStyle.Width(30).Render("Card Number"),
+		m.inputs[ccn].View(),
+		inputStyle.Width(6).Render("EXP"),
+		inputStyle.Width(6).Render("CVV"),
+		m.inputs[exp].View(),
+		m.inputs[cvv].View(),
+		continueStyle.Render("Continue ->"),
+	) + "\n"
+}
+
+// nextInput focuses the next input field
+func (m *model) nextInput() {
+	m.focused = (m.focused + 1) % len(m.inputs)
+}
+
+// prevInput focuses the previous input field
+func (m *model) prevInput() {
+	m.focused--
+	// Wrap around
+	if m.focused < 0 {
+		m.focused = len(m.inputs) - 1
+	}
+}
diff --git a/examples/exec/main.go b/examples/exec/main.go
new file mode 100644
index 0000000..709efe8
--- /dev/null
+++ b/examples/exec/main.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+type editorFinishedMsg struct{ err error }
+
+func openEditor() tea.Cmd {
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		editor = "vim"
+	}
+	c := exec.Command(editor) //nolint:gosec
+	return tea.ExecProcess(c, func(err error) tea.Msg {
+		return editorFinishedMsg{err}
+	})
+}
+
+type model struct {
+	altscreenActive bool
+	err             error
+}
+
+func (m model) Init() tea.Cmd {
+	return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "a":
+			m.altscreenActive = !m.altscreenActive
+			cmd := tea.EnterAltScreen
+			if !m.altscreenActive {
+				cmd = tea.ExitAltScreen
+			}
+			return m, cmd
+		case "e":
+			return m, openEditor()
+		case "ctrl+c", "q":
+			return m, tea.Quit
+		}
+	case editorFinishedMsg:
+		if msg.err != nil {
+			m.err = msg.err
+			return m, tea.Quit
+		}
+	}
+	return m, nil
+}
+
+func (m model) View() string {
+	if m.err != nil {
+		return "Error: " + m.err.Error() + "\n"
+	}
+	return "Press 'e' to open your EDITOR.\nPress 'a' to toggle the altscreen\nPress 'q' to quit.\n"
+}
+
+func main() {
+	m := model{}
+	if err := tea.NewProgram(m).Start(); err != nil {
+		fmt.Println("Error running program:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/glamour/main.go b/examples/glamour/main.go
index 83d385c..1768abb 100644
--- a/examples/glamour/main.go
+++ b/examples/glamour/main.go
@@ -55,7 +55,11 @@ type example struct {
 }
 
 func newExample() (*example, error) {
-	vp := viewport.Model{Width: 78, Height: 20}
+	vp := viewport.New(78, 20)
+	vp.Style = lipgloss.NewStyle().
+		BorderStyle(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("62")).
+		PaddingRight(2)
 
 	renderer, err := glamour.NewTermRenderer(glamour.WithStylePath("notty"))
 	if err != nil {
@@ -89,8 +93,8 @@ func (e example) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "q", "ctrl+c", "esc":
 			return e, tea.Quit
 		default:
-			vp, cmd := e.viewport.Update(msg)
-			e.viewport = vp
+			var cmd tea.Cmd
+			e.viewport, cmd = e.viewport.Update(msg)
 			return e, cmd
 		}
 	default:
diff --git a/examples/go.mod b/examples/go.mod
index d951ef8..022856d 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -3,16 +3,15 @@ module examples
 go 1.13
 
 require (
-	github.com/charmbracelet/bubbles v0.8.1-0.20210726164137-e723cfd15ee9
-	github.com/charmbracelet/bubbletea v0.13.1
-	github.com/charmbracelet/glamour v0.2.0
-	github.com/charmbracelet/lipgloss v0.1.2
+	github.com/charmbracelet/bubbles v0.12.1-0.20220701153126-7cc578698457
+	github.com/charmbracelet/bubbletea v0.22.0
+	github.com/charmbracelet/glamour v0.5.0
+	github.com/charmbracelet/lipgloss v0.5.0
 	github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776
 	github.com/lucasb-eyer/go-colorful v1.2.0
-	github.com/mattn/go-isatty v0.0.12
-	github.com/mattn/go-runewidth v0.0.13
-	github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
-	github.com/muesli/termenv v0.8.1
+	github.com/mattn/go-isatty v0.0.14
+	github.com/muesli/reflow v0.3.0
+	github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898
 )
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/examples/go.sum b/examples/go.sum
index 7851993..edbb7dd 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -1,85 +1,101 @@
-github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
-github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
-github.com/alecthomas/chroma v0.7.3 h1:NfdAERMy+esYQs8OXk0I868/qDxxCEo7FMz1WIqMAeI=
-github.com/alecthomas/chroma v0.7.3/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
-github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
-github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
-github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
-github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
-github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
-github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
-github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/charmbracelet/bubbles v0.8.1-0.20210726164137-e723cfd15ee9 h1:Ud8eC8EStC58Eq6lX7jC1rcY+m9TEQJ7elyLZ6SQ3Pc=
-github.com/charmbracelet/bubbles v0.8.1-0.20210726164137-e723cfd15ee9/go.mod h1:ajGUot5oksQSKxAATWfJU6STm4B0Pb1+e36bdkXrxsc=
-github.com/charmbracelet/glamour v0.2.0 h1:mTgaiNiumpqTZp3qVM6DH9UB0NlbY17wejoMf1kM8Pg=
-github.com/charmbracelet/glamour v0.2.0/go.mod h1:UA27Kwj3QHialP74iU6C+Gpc8Y7IOAKupeKMLLBURWM=
-github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
-github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
-github.com/charmbracelet/lipgloss v0.1.2 h1:D+LUMg34W7n2pkuMrevKVxT7HXqnoRHm7IoomkX3/ZU=
-github.com/charmbracelet/lipgloss v0.1.2/go.mod h1:5D8zradw52m7QmxRF6QgwbwJi9je84g8MkWiGN07uKg=
-github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
-github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
-github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
-github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
+github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
+github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/charmbracelet/bubbles v0.11.1-0.20220622203329-a207867ff1b6 h1:xoDdrxsnmEt9SUVX8t1YBZ6cse426wNzXkqyXXOGzXg=
+github.com/charmbracelet/bubbles v0.11.1-0.20220622203329-a207867ff1b6/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+github.com/charmbracelet/bubbles v0.12.0 h1:fxb9U9yI60Hek3tcPmMTFya5NhvPrqpkpyMaNngFh7A=
+github.com/charmbracelet/bubbles v0.12.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+github.com/charmbracelet/bubbles v0.12.1-0.20220628162924-2fd583c8ef43 h1:phpOTjLqJCSm9q2mW312y+i+iD9Tyfv8v+2eqrZ7h8E=
+github.com/charmbracelet/bubbles v0.12.1-0.20220628162924-2fd583c8ef43/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+github.com/charmbracelet/bubbles v0.12.1-0.20220701153126-7cc578698457 h1:rroce7neSYjLMDTzMCYOYkUxHAFYY7I383UF0hORBo0=
+github.com/charmbracelet/bubbles v0.12.1-0.20220701153126-7cc578698457/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
+github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
+github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
+github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
+github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
+github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43 h1:xO5Bh21Ii+0p3EYp1GdFEF/Iax7VhBgMbBVCOFBZ2/Q=
+github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
+github.com/charmbracelet/lipgloss v0.5.1-0.20220615005615-2e17a8a06096 h1:ai19sA3Zyg3DARevWCbdLOWt+MfWiE3e8voBqzFOgP8=
+github.com/charmbracelet/lipgloss v0.5.1-0.20220615005615-2e17a8a06096/go.mod h1:D7uPgcyfB9T1Ug2mfJOnES17o47nz5oqIzSSVrpcviU=
+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
-github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 h1:VRIbnDWRmAh5yBdz+J6yFMF5vso1It6vn+WmM/5l7MA=
 github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOfxzWM9Cst40msBF1C2UdQgDv962oTxSuMs=
-github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
-github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
+github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
-github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
-github.com/muesli/reflow v0.1.0/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I=
-github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk=
+github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
+github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
+github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
-github.com/muesli/termenv v0.6.0/go.mod h1:SohX91w6swWA4AYU+QmPx+aSgXhWO0juiyID9UZmbpA=
-github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk=
-github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
-github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
-github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
+github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898 h1:0j+cbZdhLgpNxjg0nWCasHUA82fgWOXxxGgWNVOLS1I=
+github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898/go.mod h1:bN6sPNtkiahdhHv2Xm6RGU16LSCxfbIZvMfqjOCfrR4=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
-github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
+github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/yuin/goldmark v1.2.0 h1:WOOcyaJPlzb8fZ8TloxFe8QZkhOOJx87leDa9MIT9dc=
-github.com/yuin/goldmark v1.2.0/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
+github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
+github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
+github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/help/main.go b/examples/help/main.go
new file mode 100644
index 0000000..e1ecc01
--- /dev/null
+++ b/examples/help/main.go
@@ -0,0 +1,147 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/help"
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// keyMap defines a set of keybindings. To work for help it must satisfy
+// key.Map. It could also very easily be a map[string]key.Binding.
+type keyMap struct {
+	Up    key.Binding
+	Down  key.Binding
+	Left  key.Binding
+	Right key.Binding
+	Help  key.Binding
+	Quit  key.Binding
+}
+
+// ShortHelp returns keybindings to be shown in the mini help view. It's part
+// of the key.Map interface.
+func (k keyMap) ShortHelp() []key.Binding {
+	return []key.Binding{k.Help, k.Quit}
+}
+
+// FullHelp returns keybindings for the expanded help view. It's part of the
+// key.Map interface.
+func (k keyMap) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{k.Up, k.Down, k.Left, k.Right}, // first column
+		{k.Help, k.Quit},                // second column
+	}
+}
+
+var keys = keyMap{
+	Up: key.NewBinding(
+		key.WithKeys("up", "k"),
+		key.WithHelp("↑/k", "move up"),
+	),
+	Down: key.NewBinding(
+		key.WithKeys("down", "j"),
+		key.WithHelp("↓/j", "move down"),
+	),
+	Left: key.NewBinding(
+		key.WithKeys("left", "h"),
+		key.WithHelp("←/h", "move left"),
+	),
+	Right: key.NewBinding(
+		key.WithKeys("right", "l"),
+		key.WithHelp("→/l", "move right"),
+	),
+	Help: key.NewBinding(
+		key.WithKeys("?"),
+		key.WithHelp("?", "toggle help"),
+	),
+	Quit: key.NewBinding(
+		key.WithKeys("q", "esc", "ctrl+c"),
+		key.WithHelp("q", "quit"),
+	),
+}
+
+type model struct {
+	keys       keyMap
+	help       help.Model
+	inputStyle lipgloss.Style
+	lastKey    string
+	quitting   bool
+}
+
+func newModel() model {
+	return model{
+		keys:       keys,
+		help:       help.New(),
+		inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF75B7")),
+	}
+}
+
+func (m model) Init() tea.Cmd {
+	return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		// If we set a width on the help menu it can it can gracefully truncate
+		// its view as needed.
+		m.help.Width = msg.Width
+
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, m.keys.Up):
+			m.lastKey = "↑"
+		case key.Matches(msg, m.keys.Down):
+			m.lastKey = "↓"
+		case key.Matches(msg, m.keys.Left):
+			m.lastKey = "←"
+		case key.Matches(msg, m.keys.Right):
+			m.lastKey = "→"
+		case key.Matches(msg, m.keys.Help):
+			m.help.ShowAll = !m.help.ShowAll
+		case key.Matches(msg, m.keys.Quit):
+			m.quitting = true
+			return m, tea.Quit
+		}
+	}
+
+	return m, nil
+}
+
+func (m model) View() string {
+	if m.quitting {
+		return "Bye!\n"
+	}
+
+	var status string
+	if m.lastKey == "" {
+		status = "Waiting for input..."
+	} else {
+		status = "You chose: " + m.inputStyle.Render(m.lastKey)
+	}
+
+	helpView := m.help.View(m.keys)
+	height := 8 - strings.Count(status, "\n") - strings.Count(helpView, "\n")
+
+	return "\n" + status + strings.Repeat("\n", height) + helpView
+}
+
+func main() {
+	if os.Getenv("HELP_DEBUG") != "" {
+		if f, err := tea.LogToFile("debug.log", "help"); err != nil {
+			fmt.Println("Couldn't open a file for logging:", err)
+			os.Exit(1)
+		} else {
+			defer f.Close()
+		}
+	}
+
+	if err := tea.NewProgram(newModel()).Start(); err != nil {
+		fmt.Printf("Could not start program :(\n%v\n", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/list-default/main.go b/examples/list-default/main.go
new file mode 100644
index 0000000..5c5fcc8
--- /dev/null
+++ b/examples/list-default/main.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+var docStyle = lipgloss.NewStyle().Margin(1, 2)
+
+type item struct {
+	title, desc string
+}
+
+func (i item) Title() string       { return i.title }
+func (i item) Description() string { return i.desc }
+func (i item) FilterValue() string { return i.title }
+
+type model struct {
+	list list.Model
+}
+
+func (m model) Init() tea.Cmd {
+	return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		if msg.String() == "ctrl+c" {
+			return m, tea.Quit
+		}
+	case tea.WindowSizeMsg:
+		h, v := docStyle.GetFrameSize()
+		m.list.SetSize(msg.Width-h, msg.Height-v)
+	}
+
+	var cmd tea.Cmd
+	m.list, cmd = m.list.Update(msg)
+	return m, cmd
+}
+
+func (m model) View() string {
+	return docStyle.Render(m.list.View())
+}
+
+func main() {
+	items := []list.Item{
+		item{title: "Raspberry Pi’s", desc: "I have ’em all over my house"},
+		item{title: "Nutella", desc: "It's good on toast"},
+		item{title: "Bitter melon", desc: "It cools you down"},
+		item{title: "Nice socks", desc: "And by that I mean socks without holes"},
+		item{title: "Eight hours of sleep", desc: "I had this once"},
+		item{title: "Cats", desc: "Usually"},
+		item{title: "Plantasia, the album", desc: "My plants love it too"},
+		item{title: "Pour over coffee", desc: "It takes forever to make though"},
+		item{title: "VR", desc: "Virtual reality...what is there to say?"},
+		item{title: "Noguchi Lamps", desc: "Such pleasing organic forms"},
+		item{title: "Linux", desc: "Pretty much the best OS"},
+		item{title: "Business school", desc: "Just kidding"},
+		item{title: "Pottery", desc: "Wet clay is a great feeling"},
+		item{title: "Shampoo", desc: "Nothing like clean hair"},
+		item{title: "Table tennis", desc: "It’s surprisingly exhausting"},
+		item{title: "Milk crates", desc: "Great for packing in your extra stuff"},
+		item{title: "Afternoon tea", desc: "Especially the tea sandwich part"},
+		item{title: "Stickers", desc: "The thicker the vinyl the better"},
+		item{title: "20° Weather", desc: "Celsius, not Fahrenheit"},
+		item{title: "Warm light", desc: "Like around 2700 Kelvin"},
+		item{title: "The vernal equinox", desc: "The autumnal equinox is pretty good too"},
+		item{title: "Gaffer’s tape", desc: "Basically sticky fabric"},
+		item{title: "Terrycloth", desc: "In other words, towel fabric"},
+	}
+
+	m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)}
+	m.list.Title = "My Fave Things"
+
+	p := tea.NewProgram(m, tea.WithAltScreen())
+
+	if err := p.Start(); err != nil {
+		fmt.Println("Error running program:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/list-fancy/delegate.go b/examples/list-fancy/delegate.go
new file mode 100644
index 0000000..e332b83
--- /dev/null
+++ b/examples/list-fancy/delegate.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func newItemDelegate(keys *delegateKeyMap) list.DefaultDelegate {
+	d := list.NewDefaultDelegate()
+
+	d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd {
+		var title string
+
+		if i, ok := m.SelectedItem().(item); ok {
+			title = i.Title()
+		} else {
+			return nil
+		}
+
+		switch msg := msg.(type) {
+		case tea.KeyMsg:
+			switch {
+			case key.Matches(msg, keys.choose):
+				return m.NewStatusMessage(statusMessageStyle("You chose " + title))
+
+			case key.Matches(msg, keys.remove):
+				index := m.Index()
+				m.RemoveItem(index)
+				if len(m.Items()) == 0 {
+					keys.remove.SetEnabled(false)
+				}
+				return m.NewStatusMessage(statusMessageStyle("Deleted " + title))
+			}
+		}
+
+		return nil
+	}
+
+	help := []key.Binding{keys.choose, keys.remove}
+
+	d.ShortHelpFunc = func() []key.Binding {
+		return help
+	}
+
+	d.FullHelpFunc = func() [][]key.Binding {
+		return [][]key.Binding{help}
+	}
+
+	return d
+}
+
+type delegateKeyMap struct {
+	choose key.Binding
+	remove key.Binding
+}
+
+// Additional short help entries. This satisfies the help.KeyMap interface and
+// is entirely optional.
+func (d delegateKeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		d.choose,
+		d.remove,
+	}
+}
+
+// Additional full help entries. This satisfies the help.KeyMap interface and
+// is entirely optional.
+func (d delegateKeyMap) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			d.choose,
+			d.remove,
+		},
+	}
+}
+
+func newDelegateKeyMap() *delegateKeyMap {
+	return &delegateKeyMap{
+		choose: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "choose"),
+		),
+		remove: key.NewBinding(
+			key.WithKeys("x", "backspace"),
+			key.WithHelp("x", "delete"),
+		),
+	}
+}
diff --git a/examples/list-fancy/main.go b/examples/list-fancy/main.go
new file mode 100644
index 0000000..69617cb
--- /dev/null
+++ b/examples/list-fancy/main.go
@@ -0,0 +1,190 @@
+package main
+
+import (
+	"fmt"
+	"math/rand"
+	"os"
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	appStyle = lipgloss.NewStyle().Padding(1, 2)
+
+	titleStyle = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("#FFFDF5")).
+			Background(lipgloss.Color("#25A065")).
+			Padding(0, 1)
+
+	statusMessageStyle = lipgloss.NewStyle().
+				Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#04B575"}).
+				Render
+)
+
+type item struct {
+	title       string
+	description string
+}
+
+func (i item) Title() string       { return i.title }
+func (i item) Description() string { return i.description }
+func (i item) FilterValue() string { return i.title }
+
+type listKeyMap struct {
+	toggleSpinner    key.Binding
+	toggleTitleBar   key.Binding
+	toggleStatusBar  key.Binding
+	togglePagination key.Binding
+	toggleHelpMenu   key.Binding
+	insertItem       key.Binding
+}
+
+func newListKeyMap() *listKeyMap {
+	return &listKeyMap{
+		insertItem: key.NewBinding(
+			key.WithKeys("a"),
+			key.WithHelp("a", "add item"),
+		),
+		toggleSpinner: key.NewBinding(
+			key.WithKeys("s"),
+			key.WithHelp("s", "toggle spinner"),
+		),
+		toggleTitleBar: key.NewBinding(
+			key.WithKeys("T"),
+			key.WithHelp("T", "toggle title"),
+		),
+		toggleStatusBar: key.NewBinding(
+			key.WithKeys("S"),
+			key.WithHelp("S", "toggle status"),
+		),
+		togglePagination: key.NewBinding(
+			key.WithKeys("P"),
+			key.WithHelp("P", "toggle pagination"),
+		),
+		toggleHelpMenu: key.NewBinding(
+			key.WithKeys("H"),
+			key.WithHelp("H", "toggle help"),
+		),
+	}
+}
+
+type model struct {
+	list          list.Model
+	itemGenerator *randomItemGenerator
+	keys          *listKeyMap
+	delegateKeys  *delegateKeyMap
+}
+
+func newModel() model {
+	var (
+		itemGenerator randomItemGenerator
+		delegateKeys  = newDelegateKeyMap()
+		listKeys      = newListKeyMap()
+	)
+
+	// Make initial list of items
+	const numItems = 24
+	items := make([]list.Item, numItems)
+	for i := 0; i < numItems; i++ {
+		items[i] = itemGenerator.next()
+	}
+
+	// Setup list
+	delegate := newItemDelegate(delegateKeys)
+	groceryList := list.New(items, delegate, 0, 0)
+	groceryList.Title = "Groceries"
+	groceryList.Styles.Title = titleStyle
+	groceryList.AdditionalFullHelpKeys = func() []key.Binding {
+		return []key.Binding{
+			listKeys.toggleSpinner,
+			listKeys.insertItem,
+			listKeys.toggleTitleBar,
+			listKeys.toggleStatusBar,
+			listKeys.togglePagination,
+			listKeys.toggleHelpMenu,
+		}
+	}
+
+	return model{
+		list:          groceryList,
+		keys:          listKeys,
+		delegateKeys:  delegateKeys,
+		itemGenerator: &itemGenerator,
+	}
+}
+
+func (m model) Init() tea.Cmd {
+	return tea.EnterAltScreen
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		h, v := appStyle.GetFrameSize()
+		m.list.SetSize(msg.Width-h, msg.Height-v)
+
+	case tea.KeyMsg:
+		// Don't match any of the keys below if we're actively filtering.
+		if m.list.FilterState() == list.Filtering {
+			break
+		}
+
+		switch {
+		case key.Matches(msg, m.keys.toggleSpinner):
+			cmd := m.list.ToggleSpinner()
+			return m, cmd
+
+		case key.Matches(msg, m.keys.toggleTitleBar):
+			v := !m.list.ShowTitle()
+			m.list.SetShowTitle(v)
+			m.list.SetShowFilter(v)
+			m.list.SetFilteringEnabled(v)
+			return m, nil
+
+		case key.Matches(msg, m.keys.toggleStatusBar):
+			m.list.SetShowStatusBar(!m.list.ShowStatusBar())
+			return m, nil
+
+		case key.Matches(msg, m.keys.togglePagination):
+			m.list.SetShowPagination(!m.list.ShowPagination())
+			return m, nil
+
+		case key.Matches(msg, m.keys.toggleHelpMenu):
+			m.list.SetShowHelp(!m.list.ShowHelp())
+			return m, nil
+
+		case key.Matches(msg, m.keys.insertItem):
+			m.delegateKeys.remove.SetEnabled(true)
+			newItem := m.itemGenerator.next()
+			insCmd := m.list.InsertItem(0, newItem)
+			statusCmd := m.list.NewStatusMessage(statusMessageStyle("Added " + newItem.Title()))
+			return m, tea.Batch(insCmd, statusCmd)
+		}
+	}
+
+	// This will also call our delegate's update function.
+	newListModel, cmd := m.list.Update(msg)
+	m.list = newListModel
+	cmds = append(cmds, cmd)
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m model) View() string {
+	return appStyle.Render(m.list.View())
+}
+
+func main() {
+	rand.Seed(time.Now().UTC().UnixNano())
+
+	if err := tea.NewProgram(newModel()).Start(); err != nil {
+		fmt.Println("Error running program:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/list-fancy/randomitems.go b/examples/list-fancy/randomitems.go
new file mode 100644
index 0000000..9a06cd9
--- /dev/null
+++ b/examples/list-fancy/randomitems.go
@@ -0,0 +1,164 @@
+package main
+
+import (
+	"math/rand"
+	"sync"
+)
+
+type randomItemGenerator struct {
+	titles     []string
+	descs      []string
+	titleIndex int
+	descIndex  int
+	mtx        *sync.Mutex
+	shuffle    *sync.Once
+}
+
+func (r *randomItemGenerator) reset() {
+	r.mtx = &sync.Mutex{}
+	r.shuffle = &sync.Once{}
+
+	r.titles = []string{
+		"Artichoke",
+		"Baking Flour",
+		"Bananas",
+		"Barley",
+		"Bean Sprouts",
+		"Bitter Melon",
+		"Black Cod",
+		"Blood Orange",
+		"Brown Sugar",
+		"Cashew Apple",
+		"Cashews",
+		"Cat Food",
+		"Coconut Milk",
+		"Cucumber",
+		"Curry Paste",
+		"Currywurst",
+		"Dill",
+		"Dragonfruit",
+		"Dried Shrimp",
+		"Eggs",
+		"Fish Cake",
+		"Furikake",
+		"Garlic",
+		"Gherkin",
+		"Ginger",
+		"Granulated Sugar",
+		"Grapefruit",
+		"Green Onion",
+		"Hazelnuts",
+		"Heavy whipping cream",
+		"Honey Dew",
+		"Horseradish",
+		"Jicama",
+		"Kohlrabi",
+		"Leeks",
+		"Lentils",
+		"Licorice Root",
+		"Meyer Lemons",
+		"Milk",
+		"Molasses",
+		"Muesli",
+		"Nectarine",
+		"Niagamo Root",
+		"Nopal",
+		"Nutella",
+		"Oat Milk",
+		"Oatmeal",
+		"Olives",
+		"Papaya",
+		"Party Gherkin",
+		"Peppers",
+		"Persian Lemons",
+		"Pickle",
+		"Pineapple",
+		"Plantains",
+		"Pocky",
+		"Powdered Sugar",
+		"Quince",
+		"Radish",
+		"Ramps",
+		"Star Anise",
+		"Sweet Potato",
+		"Tamarind",
+		"Unsalted Butter",
+		"Watermelon",
+		"Weißwurst",
+		"Yams",
+		"Yeast",
+		"Yuzu",
+		"Snow Peas",
+	}
+
+	r.descs = []string{
+		"A little weird",
+		"Bold flavor",
+		"Can’t get enough",
+		"Delectable",
+		"Expensive",
+		"Expired",
+		"Exquisite",
+		"Fresh",
+		"Gimme",
+		"In season",
+		"Kind of spicy",
+		"Looks fresh",
+		"Looks good to me",
+		"Maybe not",
+		"My favorite",
+		"Oh my",
+		"On sale",
+		"Organic",
+		"Questionable",
+		"Really fresh",
+		"Refreshing",
+		"Salty",
+		"Scrumptious",
+		"Delectable",
+		"Slightly sweet",
+		"Smells great",
+		"Tasty",
+		"Too ripe",
+		"At last",
+		"What?",
+		"Wow",
+		"Yum",
+		"Maybe",
+		"Sure, why not?",
+	}
+
+	r.shuffle.Do(func() {
+		shuf := func(x []string) {
+			rand.Shuffle(len(x), func(i, j int) { x[i], x[j] = x[j], x[i] })
+		}
+		shuf(r.titles)
+		shuf(r.descs)
+	})
+}
+
+func (r *randomItemGenerator) next() item {
+	if r.mtx == nil {
+		r.reset()
+	}
+
+	r.mtx.Lock()
+	defer r.mtx.Unlock()
+
+	i := item{
+		title:       r.titles[r.titleIndex],
+		description: r.descs[r.descIndex],
+	}
+
+	r.titleIndex++
+	if r.titleIndex >= len(r.titles) {
+		r.titleIndex = 0
+	}
+
+	r.descIndex++
+	if r.descIndex >= len(r.descs) {
+		r.descIndex = 0
+	}
+
+	return i
+}
diff --git a/examples/list-simple/main.go b/examples/list-simple/main.go
new file mode 100644
index 0000000..97628db
--- /dev/null
+++ b/examples/list-simple/main.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+const listHeight = 14
+
+var (
+	titleStyle        = lipgloss.NewStyle().MarginLeft(2)
+	itemStyle         = lipgloss.NewStyle().PaddingLeft(4)
+	selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
+	paginationStyle   = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
+	helpStyle         = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
+	quitTextStyle     = lipgloss.NewStyle().Margin(1, 0, 2, 4)
+)
+
+type item string
+
+func (i item) FilterValue() string { return "" }
+
+type itemDelegate struct{}
+
+func (d itemDelegate) Height() int                               { return 1 }
+func (d itemDelegate) Spacing() int                              { return 0 }
+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	i, ok := listItem.(item)
+	if !ok {
+		return
+	}
+
+	str := fmt.Sprintf("%d. %s", index+1, i)
+
+	fn := itemStyle.Render
+	if index == m.Index() {
+		fn = func(s string) string {
+			return selectedItemStyle.Render("> " + s)
+		}
+	}
+
+	fmt.Fprintf(w, fn(str))
+}
+
+type model struct {
+	list     list.Model
+	items    []item
+	choice   string
+	quitting bool
+}
+
+func (m model) Init() tea.Cmd {
+	return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.list.SetWidth(msg.Width)
+		return m, nil
+
+	case tea.KeyMsg:
+		switch keypress := msg.String(); keypress {
+		case "ctrl+c":
+			m.quitting = true
+			return m, tea.Quit
+
+		case "enter":
+			i, ok := m.list.SelectedItem().(item)
+			if ok {
+				m.choice = string(i)
+			}
+			return m, tea.Quit
+		}
+	}
+
+	var cmd tea.Cmd
+	m.list, cmd = m.list.Update(msg)
+	return m, cmd
+}
+
+func (m model) View() string {
+	if m.choice != "" {
+		return quitTextStyle.Render(fmt.Sprintf("%s? Sounds good to me.", m.choice))
+	}
+	if m.quitting {
+		return quitTextStyle.Render("Not hungry? That’s cool.")
+	}
+	return "\n" + m.list.View()
+}
+
+func main() {
+	items := []list.Item{
+		item("Ramen"),
+		item("Tomato Soup"),
+		item("Hamburgers"),
+		item("Cheeseburgers"),
+		item("Currywurst"),
+		item("Okonomiyaki"),
+		item("Pasta"),
+		item("Fillet Mignon"),
+		item("Caviar"),
+		item("Just Wine"),
+	}
+
+	const defaultWidth = 20
+
+	l := list.New(items, itemDelegate{}, defaultWidth, listHeight)
+	l.Title = "What do you want for dinner?"
+	l.SetShowStatusBar(false)
+	l.SetFilteringEnabled(false)
+	l.Styles.Title = titleStyle
+	l.Styles.PaginationStyle = paginationStyle
+	l.Styles.HelpStyle = helpStyle
+
+	m := model{list: l}
+
+	if err := tea.NewProgram(m).Start(); err != nil {
+		fmt.Println("Error running program:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/package-manager/main.go b/examples/package-manager/main.go
new file mode 100644
index 0000000..e6f20ec
--- /dev/null
+++ b/examples/package-manager/main.go
@@ -0,0 +1,139 @@
+package main
+
+import (
+	"fmt"
+	"math/rand"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/progress"
+	"github.com/charmbracelet/bubbles/spinner"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+type model struct {
+	packages []string
+	index    int
+	width    int
+	height   int
+	spinner  spinner.Model
+	progress progress.Model
+	done     bool
+}
+
+var (
+	currentPkgNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211"))
+	subtleStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
+	doneStyle           = lipgloss.NewStyle().Margin(1, 2)
+	checkMark           = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓")
+)
+
+func newModel() model {
+	p := progress.New(
+		progress.WithDefaultGradient(),
+		progress.WithWidth(40),
+		progress.WithoutPercentage(),
+	)
+	s := spinner.New()
+	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
+	return model{
+		packages: getPackages(),
+		spinner:  s,
+		progress: p,
+	}
+}
+
+func (m model) Init() tea.Cmd {
+	return tea.Batch(downloadAndInstall(m.packages[m.index]), m.spinner.Tick)
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width, m.height = msg.Width, msg.Height
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c", "esc", "q":
+			return m, tea.Quit
+		}
+	case installedPkgMsg:
+		if m.index >= len(m.packages)-1 {
+			// Everything's been installed. We're done!
+			m.done = true
+			return m, tea.Quit
+		}
+
+		// Update progress bar
+		progressCmd := m.progress.SetPercent(float64(m.index) / float64(len(m.packages)-1))
+
+		m.index++
+		return m, tea.Batch(
+			progressCmd,
+			tea.Printf("%s %s", checkMark, m.packages[m.index]), // print success message above our program
+			downloadAndInstall(m.packages[m.index]),             // download the next package
+		)
+	case spinner.TickMsg:
+		var cmd tea.Cmd
+		m.spinner, cmd = m.spinner.Update(msg)
+		return m, cmd
+	case progress.FrameMsg:
+		newModel, cmd := m.progress.Update(msg)
+		if newModel, ok := newModel.(progress.Model); ok {
+			m.progress = newModel
+		}
+		return m, cmd
+	}
+	return m, nil
+}
+
+func (m model) View() string {
+	n := len(m.packages)
+	w := lipgloss.Width(fmt.Sprintf("%d", n))
+
+	if m.done {
+		return doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n))
+	}
+
+	pkgCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, n-1)
+
+	spin := m.spinner.View() + " "
+	prog := m.progress.View()
+	cellsAvail := max(0, m.width-lipgloss.Width(spin+prog+pkgCount))
+
+	pkgName := currentPkgNameStyle.Render(m.packages[m.index])
+	info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + pkgName)
+
+	cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+pkgCount))
+	gap := strings.Repeat(" ", cellsRemaining)
+
+	return spin + info + gap + prog + pkgCount
+}
+
+type installedPkgMsg string
+
+func downloadAndInstall(pkg string) tea.Cmd {
+	// This is where you'd do i/o stuff to download and install packages. In
+	// our case we're just pausing for a moment to simulate the process.
+	d := time.Millisecond * time.Duration(rand.Intn(500))
+	return tea.Tick(d, func(t time.Time) tea.Msg {
+		return installedPkgMsg(pkg)
+	})
+}
+
+func max(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}
+
+func main() {
+	rand.Seed(time.Now().Unix())
+
+	if err := tea.NewProgram(newModel()).Start(); err != nil {
+		fmt.Println("Error running program:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/package-manager/packages.go b/examples/package-manager/packages.go
new file mode 100644
index 0000000..7ed8478
--- /dev/null
+++ b/examples/package-manager/packages.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+	"fmt"
+	"math/rand"
+)
+
+var packages = []string{
+	"vegeutils",
+	"libgardening",
+	"currykit",
+	"spicerack",
+	"fullenglish",
+	"eggy",
+	"bad-kitty",
+	"chai",
+	"hojicha",
+	"libtacos",
+	"babys-monads",
+	"libpurring",
+	"currywurst-devel",
+	"xmodmeow",
+	"licorice-utils",
+	"cashew-apple",
+	"rock-lobster",
+	"standmixer",
+	"coffee-CUPS",
+	"libesszet",
+	"zeichenorientierte-benutzerschnittstellen",
+	"schnurrkit",
+	"old-socks-devel",
+	"jalapeño",
+	"molasses-utils",
+	"xkohlrabi",
+	"party-gherkin",
+	"snow-peas",
+	"libyuzu",
+}
+
+func getPackages() []string {
+	pkgs := packages
+	copy(pkgs, packages)
+
+	rand.Shuffle(len(pkgs), func(i, j int) {
+		pkgs[i], pkgs[j] = pkgs[j], pkgs[i]
+	})
+
+	for k := range pkgs {
+		pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10))
+	}
+	return pkgs
+}
diff --git a/examples/pager/main.go b/examples/pager/main.go
index cd7038f..67b6a48 100644
--- a/examples/pager/main.go
+++ b/examples/pager/main.go
@@ -11,64 +11,31 @@ import (
 
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
-	"github.com/mattn/go-runewidth"
+	"github.com/charmbracelet/lipgloss"
 )
 
-const (
-	// You generally won't need this unless you're processing stuff with some
-	// pretty complicated ANSI escape sequences. Turn it on if you notice
-	// flickering.
-	//
-	// Also note that high performance rendering only works for programs that
-	// use the full size of the terminal. We're enabling that below with
-	// tea.EnterAltScreen().
-	useHighPerformanceRenderer = false
-
-	headerHeight = 3
-	footerHeight = 3
+// You generally won't need this unless you're processing stuff with
+// complicated ANSI escape sequences. Turn it on if you notice flickering.
+//
+// Also keep in mind that high performance rendering only works for programs
+// that use the full size of the terminal. We're enabling that below with
+// tea.EnterAltScreen().
+const useHighPerformanceRenderer = false
+
+var (
+	titleStyle = func() lipgloss.Style {
+		b := lipgloss.RoundedBorder()
+		b.Right = "├"
+		return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1)
+	}()
+
+	infoStyle = func() lipgloss.Style {
+		b := lipgloss.RoundedBorder()
+		b.Left = "┤"
+		return titleStyle.Copy().BorderStyle(b)
+	}()
 )
 
-func main() {
-
-	// Load some text to render
-	content, err := ioutil.ReadFile("artichoke.md")
-	if err != nil {
-		fmt.Println("could not load file:", err)
-		os.Exit(1)
-	}
-
-	// Set PAGER_LOG to a path to log to a file. For example:
-	//
-	//     export PAGER_LOG=debug.log
-	//
-	// This becomes handy when debugging stuff since you can't debug to stdout
-	// because the UI is occupying it!
-	path := os.Getenv("PAGER_LOG")
-	if path != "" {
-		f, err := tea.LogToFile(path, "pager")
-		if err != nil {
-			fmt.Printf("Could not open file %s: %v", path, err)
-			os.Exit(1)
-		}
-		defer f.Close()
-	}
-
-	p := tea.NewProgram(
-		model{content: string(content)},
-
-		// Use the full size of the terminal in its "alternate screen buffer"
-		tea.WithAltScreen(),
-
-		// Also turn on mouse support so we can track the mouse wheel
-		tea.WithMouseCellMotion(),
-	)
-
-	if err := p.Start(); err != nil {
-		fmt.Println("could not run program:", err)
-		os.Exit(1)
-	}
-}
-
 type model struct {
 	content  string
 	ready    bool
@@ -92,22 +59,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case tea.WindowSizeMsg:
-		verticalMargins := headerHeight + footerHeight
+		headerHeight := lipgloss.Height(m.headerView())
+		footerHeight := lipgloss.Height(m.footerView())
+		verticalMarginHeight := headerHeight + footerHeight
 
 		if !m.ready {
-			// Since this program is using the full size of the viewport we need
-			// to wait until we've received the window dimensions before we
-			// can initialize the viewport. The initial dimensions come in
+			// Since this program is using the full size of the viewport we
+			// need to wait until we've received the window dimensions before
+			// we can initialize the viewport. The initial dimensions come in
 			// quickly, though asynchronously, which is why we wait for them
 			// here.
-			m.viewport = viewport.Model{Width: msg.Width, Height: msg.Height - verticalMargins}
+			m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
 			m.viewport.YPosition = headerHeight
 			m.viewport.HighPerformanceRendering = useHighPerformanceRenderer
 			m.viewport.SetContent(m.content)
 			m.ready = true
+
+			// This is only necessary for high performance rendering, which in
+			// most cases you won't need.
+			//
+			// Render the viewport one line below the header.
+			m.viewport.YPosition = headerHeight + 1
 		} else {
 			m.viewport.Width = msg.Width
-			m.viewport.Height = msg.Height - verticalMargins
+			m.viewport.Height = msg.Height - verticalMarginHeight
 		}
 
 		if useHighPerformanceRenderer {
@@ -119,16 +94,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 
-	// Because we're using the viewport's default update function (with pager-
-	// style navigation) it's important that the viewport's update function:
-	//
-	// * Receives messages from the Bubble Tea runtime
-	// * Returns commands to the Bubble Tea runtime
-	//
+	// Handle keyboard and mouse events in the viewport
 	m.viewport, cmd = m.viewport.Update(msg)
-	if useHighPerformanceRenderer {
-		cmds = append(cmds, cmd)
-	}
+	cmds = append(cmds, cmd)
 
 	return m, tea.Batch(cmds...)
 }
@@ -137,21 +105,44 @@ func (m model) View() string {
 	if !m.ready {
 		return "\n  Initializing..."
 	}
+	return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
+}
+
+func (m model) headerView() string {
+	title := titleStyle.Render("Mr. Pager")
+	line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)))
+	return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
+}
+
+func (m model) footerView() string {
+	info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
+	line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)))
+	return lipgloss.JoinHorizontal(lipgloss.Center, line, info)
+}
+
+func max(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}
+
+func main() {
+	// Load some text for our viewport
+	content, err := ioutil.ReadFile("artichoke.md")
+	if err != nil {
+		fmt.Println("could not load file:", err)
+		os.Exit(1)
+	}
+
+	p := tea.NewProgram(
+		model{content: string(content)},
+		tea.WithAltScreen(),       // use the full size of the terminal in its "alternate screen buffer"
+		tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel
+	)
 
-	headerTop := "╭───────────╮"
-	headerMid := "│ Mr. Pager ├"
-	headerBot := "╰───────────╯"
-	headerMid += strings.Repeat("─", m.viewport.Width-runewidth.StringWidth(headerMid))
-	header := fmt.Sprintf("%s\n%s\n%s", headerTop, headerMid, headerBot)
-
-	footerTop := "╭──────╮"
-	footerMid := fmt.Sprintf("┤ %3.f%% │", m.viewport.ScrollPercent()*100)
-	footerBot := "╰──────╯"
-	gapSize := m.viewport.Width - runewidth.StringWidth(footerMid)
-	footerTop = strings.Repeat(" ", gapSize) + footerTop
-	footerMid = strings.Repeat("─", gapSize) + footerMid
-	footerBot = strings.Repeat(" ", gapSize) + footerBot
-	footer := fmt.Sprintf("%s\n%s\n%s", footerTop, footerMid, footerBot)
-
-	return fmt.Sprintf("%s\n%s\n%s", header, m.viewport.View(), footer)
+	if err := p.Start(); err != nil {
+		fmt.Println("could not run program:", err)
+		os.Exit(1)
+	}
 }
diff --git a/examples/paginator/main.go b/examples/paginator/main.go
index 9aa042c..35356d5 100644
--- a/examples/paginator/main.go
+++ b/examples/paginator/main.go
@@ -21,7 +21,7 @@ func newModel() model {
 		items = append(items, text)
 	}
 
-	p := paginator.NewModel()
+	p := paginator.New()
 	p.Type = paginator.Dots
 	p.PerPage = 10
 	p.ActiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•")
diff --git a/examples/pipe/main.go b/examples/pipe/main.go
index b44503b..b138f9e 100644
--- a/examples/pipe/main.go
+++ b/examples/pipe/main.go
@@ -56,7 +56,7 @@ type model struct {
 }
 
 func newModel(initialValue string) (m model) {
-	i := textinput.NewModel()
+	i := textinput.New()
 	i.Prompt = ""
 	i.CursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
 	i.Width = 48
diff --git a/examples/progress-animated/main.go b/examples/progress-animated/main.go
index 9d6bb9d..ca6b610 100644
--- a/examples/progress-animated/main.go
+++ b/examples/progress-animated/main.go
@@ -27,7 +27,7 @@ var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render
 
 func main() {
 	m := model{
-		progress: progress.NewModel(progress.WithDefaultGradient()),
+		progress: progress.New(progress.WithDefaultGradient()),
 	}
 
 	if err := tea.NewProgram(m).Start(); err != nil {
@@ -70,8 +70,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	// FrameMsg is sent when the progress bar wants to animate itself
 	case progress.FrameMsg:
-		var cmd tea.Cmd
-		m.progress, cmd = m.progress.Update(msg)
+		progressModel, cmd := m.progress.Update(msg)
+		m.progress = progressModel.(progress.Model)
 		return m, cmd
 
 	default:
@@ -87,7 +87,7 @@ func (e model) View() string {
 }
 
 func tickCmd() tea.Cmd {
-	return tea.Tick(time.Second*2, func(t time.Time) tea.Msg {
+	return tea.Tick(time.Second*1, func(t time.Time) tea.Msg {
 		return tickMsg(t)
 	})
 }
diff --git a/examples/progress-download/README.md b/examples/progress-download/README.md
new file mode 100644
index 0000000..cdb4535
--- /dev/null
+++ b/examples/progress-download/README.md
@@ -0,0 +1,34 @@
+# Download Progress
+
+This example demonstrates how to download a file from a URL and show its
+progress with a [Progress Bubble][progress].
+
+In this case we're getting download progress with an [`io.TeeReader`][tee] and
+sending progress `Msg`s to the `Program` with `Program.Send()`.
+
+## How to Run
+
+Build the application with `go build .`, then run with a `--url` argument
+specifying the URL of the file to download. For example:
+
+```
+./download-progress --url="https://download.blender.org/demo/color_vortex.blend"
+```
+
+Note that in this example a TUI will not be shown for URLs that do not respond
+with a ContentLength header.
+
+* * *
+
+This example originally came from [this discussion][discussion].
+
+* * *
+
+<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
+
+Charm热爱开源 • Charm loves open source
+
+
+[progress]: https://github.com/charmbracelet/bubbles/
+[tee]: https://pkg.go.dev/io#TeeReader
+[discussion]: https://github.com/charmbracelet/bubbles/discussions/127
diff --git a/examples/progress-download/main.go b/examples/progress-download/main.go
new file mode 100644
index 0000000..601aaec
--- /dev/null
+++ b/examples/progress-download/main.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+
+	"github.com/charmbracelet/bubbles/progress"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+var p *tea.Program
+
+type progressWriter struct {
+	total      int
+	downloaded int
+	file       *os.File
+	reader     io.Reader
+	onProgress func(float64)
+}
+
+func (pw *progressWriter) Start() {
+	// TeeReader calls pw.Write() each time a new response is received
+	_, err := io.Copy(pw.file, io.TeeReader(pw.reader, pw))
+	if err != nil {
+		if p != nil {
+			p.Send(progressErrMsg{err})
+		}
+	}
+}
+
+func (pw *progressWriter) Write(p []byte) (int, error) {
+	pw.downloaded += len(p)
+	if pw.total > 0 && pw.onProgress != nil {
+		pw.onProgress(float64(pw.downloaded) / float64(pw.total))
+	}
+	return len(p), nil
+}
+
+func getResponse(url string) (*http.Response, error) {
+	resp, err := http.Get(url)
+	if err != nil {
+		log.Fatal(err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("receiving status of %d for url: %s", resp.StatusCode, url)
+	}
+	return resp, nil
+}
+
+func main() {
+	url := flag.String("url", "", "url for the file to download")
+	flag.Parse()
+
+	if *url == "" {
+		flag.Usage()
+		os.Exit(1)
+	}
+
+	resp, err := getResponse(*url)
+	if err != nil {
+		fmt.Println("could not get response", err)
+		os.Exit(1)
+	}
+	defer resp.Body.Close()
+
+	filename := filepath.Base(*url)
+	file, err := os.Create(filename)
+	if err != nil {
+		fmt.Println("could not create file: ", err)
+		os.Exit(1)
+	}
+	defer file.Close()
+
+	pw := &progressWriter{
+		total:  int(resp.ContentLength),
+		file:   file,
+		reader: resp.Body,
+		onProgress: func(ratio float64) {
+			if p != nil {
+				p.Send(progressMsg(ratio))
+			}
+		},
+	}
+
+	m := model{
+		pw:       pw,
+		progress: progress.New(progress.WithDefaultGradient()),
+	}
+
+	// Start the download
+	go pw.Start()
+
+	// Don't add TUI if the header doesn't include content size
+	// it's impossible see progress without total
+	if resp.ContentLength > 0 {
+		// Start Bubble Tea
+		p = tea.NewProgram(m)
+		if err := p.Start(); err != nil {
+			fmt.Println("error running program:", err)
+			os.Exit(1)
+		}
+	}
+}
diff --git a/examples/progress-download/tui.go b/examples/progress-download/tui.go
new file mode 100644
index 0000000..a9b5c7b
--- /dev/null
+++ b/examples/progress-download/tui.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/progress"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render
+
+const (
+	padding  = 2
+	maxWidth = 80
+)
+
+type progressMsg float64
+
+type progressErrMsg struct{ err error }
+
+func finalPause() tea.Cmd {
+	return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg {
+		return nil
+	})
+}
+
+type model struct {
+	url, path string
+	pw        *progressWriter
+	progress  progress.Model
+	err       error
+}
+
+func (m model) Init() tea.Cmd {
+	return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		return m, tea.Quit
+
+	case tea.WindowSizeMsg:
+		m.progress.Width = msg.Width - padding*2 - 4
+		if m.progress.Width > maxWidth {
+			m.progress.Width = maxWidth
+		}
+		return m, nil
+
+	case progressErrMsg:
+		m.err = msg.err
+		return m, tea.Quit
+
+	case progressMsg:
+		var cmds []tea.Cmd
+
+		if msg >= 1.0 {
+			cmds = append(cmds, tea.Sequentially(finalPause(), tea.Quit))
+		}
+
+		cmds = append(cmds, m.progress.SetPercent(float64(msg)))
+		return m, tea.Batch(cmds...)
+
+	// FrameMsg is sent when the progress bar wants to animate itself
+	case progress.FrameMsg:
+		progressModel, cmd := m.progress.Update(msg)
+		m.progress = progressModel.(progress.Model)
+		return m, cmd
+
+	default:
+		return m, nil
+	}
+}
+
+func (m model) View() string {
+	if m.err != nil {
+		return "Error downloading: " + m.err.Error() + "\n"
+	}
+
+	pad := strings.Repeat(" ", padding)
+	return "\n" +
+		pad + m.progress.View() + "\n\n" +
+		pad + helpStyle("Press any key to quit")
+}
diff --git a/examples/progress-static/main.go b/examples/progress-static/main.go
index edcc202..ba74cb6 100644
--- a/examples/progress-static/main.go
+++ b/examples/progress-static/main.go
@@ -35,7 +35,7 @@ const (
 var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render
 
 func main() {
-	prog := progress.NewModel(progress.WithScaledGradient("#FF7CCB", "#FDFF8C"))
+	prog := progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C"))
 
 	if err := tea.NewProgram(model{progress: prog}).Start(); err != nil {
 		fmt.Println("Oh no!", err)
diff --git a/examples/realtime/main.go b/examples/realtime/main.go
index 3ab780e..3390d81 100644
--- a/examples/realtime/main.go
+++ b/examples/realtime/main.go
@@ -82,7 +82,7 @@ func main() {
 
 	p := tea.NewProgram(model{
 		sub:     make(chan struct{}),
-		spinner: spinner.NewModel(),
+		spinner: spinner.New(),
 	})
 
 	if p.Start() != nil {
diff --git a/examples/result/main.go b/examples/result/main.go
index 323eb1d..a73b17d 100644
--- a/examples/result/main.go
+++ b/examples/result/main.go
@@ -2,8 +2,6 @@ package main
 
 // A simple example that shows how to retrieve a value from a Bubble Tea
 // program after the Bubble Tea has exited.
-//
-// Thanks to Treilik for this one.
 
 import (
 	"fmt"
@@ -17,7 +15,7 @@ var choices = []string{"Taro", "Coffee", "Lychee"}
 
 type model struct {
 	cursor int
-	choice chan string
+	choice string
 }
 
 func (m model) Init() tea.Cmd {
@@ -29,12 +27,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyMsg:
 		switch msg.String() {
 		case "ctrl+c", "q", "esc":
-			close(m.choice) // If we're quitting just close the channel.
 			return m, tea.Quit
 
 		case "enter":
 			// Send the choice on the channel and exit.
-			m.choice <- choices[m.cursor]
+			m.choice = choices[m.cursor]
 			return m, tea.Quit
 
 		case "down", "j":
@@ -74,20 +71,17 @@ func (m model) View() string {
 }
 
 func main() {
-	// This is where we'll listen for the choice the user makes in the Bubble
-	// Tea program.
-	result := make(chan string, 1)
-
-	// Pass the channel to the initialize function so our Bubble Tea program
-	// can send the final choice along when the time comes.
-	p := tea.NewProgram(model{cursor: 0, choice: result})
-	if err := p.Start(); err != nil {
+	p := tea.NewProgram(model{})
+
+	// StartReturningModel returns the model as a tea.Model.
+	m, err := p.StartReturningModel()
+	if err != nil {
 		fmt.Println("Oh no:", err)
 		os.Exit(1)
 	}
 
-	// Print out the final choice.
-	if r := <-result; r != "" {
-		fmt.Printf("\n---\nYou chose %s!\n", r)
+	// Assert the final tea.Model to our local model and print the choice.
+	if m, ok := m.(model); ok && m.choice != "" {
+		fmt.Printf("\n---\nYou chose %s!\n", m.choice)
 	}
 }
diff --git a/examples/send-msg/main.go b/examples/send-msg/main.go
index f61c036..2146387 100644
--- a/examples/send-msg/main.go
+++ b/examples/send-msg/main.go
@@ -45,7 +45,7 @@ type model struct {
 
 func newModel() model {
 	const numLastResults = 5
-	s := spinner.NewModel()
+	s := spinner.New()
 	s.Style = spinnerStyle
 	return model{
 		spinner: s,
@@ -103,16 +103,7 @@ func (m model) View() string {
 func main() {
 	rand.Seed(time.Now().UTC().UnixNano())
 
-	done := make(chan struct{})
-
 	p := tea.NewProgram(newModel())
-	go func() {
-		if err := p.Start(); err != nil {
-			fmt.Println("Error running program:", err)
-			os.Exit(1)
-		}
-		close(done)
-	}()
 
 	// Simulate activity
 	go func() {
@@ -120,17 +111,17 @@ func main() {
 			pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond
 			time.Sleep(pause)
 
-			// Send the Bubble Tea program a message from outside the program.
+			// Send the Bubble Tea program a message from outside the
+			// tea.Program. This will block until it is ready to receive
+			// messages.
 			p.Send(resultMsg{food: randomFood(), duration: pause})
 		}
 	}()
 
-	<-done
-}
-
-func randomEmoji() string {
-	emojis := []rune("🍦🧋🍡🤠👾😭🦊🐯🦆🥨🎏🍔🍒🍥🎮📦🦁🐶🐸🍕🥐🧲🚒🥇🏆🌽")
-	return string(emojis[rand.Intn(len(emojis))])
+	if err := p.Start(); err != nil {
+		fmt.Println("Error running program:", err)
+		os.Exit(1)
+	}
 }
 
 func randomFood() string {
diff --git a/examples/spinner/main.go b/examples/spinner/main.go
index 47bf5a2..229542d 100644
--- a/examples/spinner/main.go
+++ b/examples/spinner/main.go
@@ -21,14 +21,14 @@ type model struct {
 }
 
 func initialModel() model {
-	s := spinner.NewModel()
+	s := spinner.New()
 	s.Spinner = spinner.Dot
 	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
 	return model{spinner: s}
 }
 
 func (m model) Init() tea.Cmd {
-	return spinner.Tick
+	return m.spinner.Tick
 }
 
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
diff --git a/examples/spinners/main.go b/examples/spinners/main.go
index fed6c5b..d84e06b 100644
--- a/examples/spinners/main.go
+++ b/examples/spinners/main.go
@@ -44,7 +44,7 @@ type model struct {
 }
 
 func (m model) Init() tea.Cmd {
-	return spinner.Tick
+	return m.spinner.Tick
 }
 
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -59,14 +59,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.index = len(spinners) - 1
 			}
 			m.resetSpinner()
-			return m, spinner.Tick
+			return m, m.spinner.Tick
 		case "l", "right":
 			m.index++
 			if m.index >= len(spinners) {
 				m.index = 0
 			}
 			m.resetSpinner()
-			return m, spinner.Tick
+			return m, m.spinner.Tick
 		default:
 			return m, nil
 		}
@@ -80,7 +80,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *model) resetSpinner() {
-	m.spinner = spinner.NewModel()
+	m.spinner = spinner.New()
 	m.spinner.Style = spinnerStyle
 	m.spinner.Spinner = spinners[m.index]
 }
diff --git a/examples/split-editors/main.go b/examples/split-editors/main.go
new file mode 100644
index 0000000..d2872c6
--- /dev/null
+++ b/examples/split-editors/main.go
@@ -0,0 +1,185 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/charmbracelet/bubbles/help"
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/textarea"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+const (
+	initialInputs = 2
+	maxInputs     = 6
+	minInputs     = 1
+)
+
+var (
+	cursorLineStyle = lipgloss.NewStyle().
+			Background(lipgloss.Color("57")).
+			Foreground(lipgloss.Color("230"))
+
+	placeholderStyle = lipgloss.NewStyle().
+				Foreground(lipgloss.Color("238"))
+
+	focusedPlaceholderStyle = lipgloss.NewStyle().
+				Foreground(lipgloss.Color("99"))
+)
+
+type keymap = struct {
+	next, prev, add, remove, quit key.Binding
+}
+
+func newTextarea() textarea.Model {
+	t := textarea.New()
+	t.SetHeight(20)
+	t.Prompt = ""
+	t.Placeholder = "Type something"
+	t.ShowLineNumbers = true
+	t.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
+	t.FocusedStyle.Placeholder = focusedPlaceholderStyle
+	t.BlurredStyle.Placeholder = placeholderStyle
+	t.FocusedStyle.CursorLine = cursorLineStyle
+	t.KeyMap.DeleteWordBackward.SetEnabled(false)
+	t.KeyMap.LineNext = key.NewBinding(key.WithKeys("down"))
+	t.KeyMap.LinePrevious = key.NewBinding(key.WithKeys("up"))
+	t.Blur()
+	return t
+}
+
+type model struct {
+	width  int
+	keymap keymap
+	help   help.Model
+	inputs []textarea.Model
+	focus  int
+}
+
+func newModel() model {
+	m := model{
+		inputs: make([]textarea.Model, initialInputs),
+		help:   help.New(),
+		keymap: keymap{
+			next: key.NewBinding(
+				key.WithKeys("tab"),
+				key.WithHelp("tab", "next"),
+			),
+			prev: key.NewBinding(
+				key.WithKeys("shift+tab"),
+				key.WithHelp("shift+tab", "prev"),
+			),
+			add: key.NewBinding(
+				key.WithKeys("ctrl+n"),
+				key.WithHelp("ctrl+n", "add an editor"),
+			),
+			remove: key.NewBinding(
+				key.WithKeys("ctrl+w"),
+				key.WithHelp("ctrl+w", "remove an editor"),
+			),
+			quit: key.NewBinding(
+				key.WithKeys("esc", "ctrl+c"),
+				key.WithHelp("esc", "quit"),
+			),
+		},
+	}
+	for i := 0; i < initialInputs; i++ {
+		m.inputs[i] = newTextarea()
+	}
+	m.inputs[m.focus].Focus()
+	m.updateKeybindings()
+	return m
+}
+
+func (m model) Init() tea.Cmd {
+	return textarea.Blink
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, m.keymap.quit):
+			for i := range m.inputs {
+				m.inputs[i].Blur()
+			}
+			return m, tea.Quit
+		case key.Matches(msg, m.keymap.next):
+			m.inputs[m.focus].Blur()
+			m.focus++
+			if m.focus > len(m.inputs)-1 {
+				m.focus = 0
+			}
+			cmd := m.inputs[m.focus].Focus()
+			cmds = append(cmds, cmd)
+		case key.Matches(msg, m.keymap.prev):
+			m.inputs[m.focus].Blur()
+			m.focus--
+			if m.focus < 0 {
+				m.focus = len(m.inputs) - 1
+			}
+			cmd := m.inputs[m.focus].Focus()
+			cmds = append(cmds, cmd)
+		case key.Matches(msg, m.keymap.add):
+			m.inputs = append(m.inputs, newTextarea())
+		case key.Matches(msg, m.keymap.remove):
+			m.inputs = m.inputs[:len(m.inputs)-1]
+			if m.focus > len(m.inputs)-1 {
+				m.focus = len(m.inputs) - 1
+			}
+		}
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+	}
+
+	m.updateKeybindings()
+	m.sizeInputs()
+
+	// Update all textareas
+	for i := range m.inputs {
+		newModel, cmd := m.inputs[i].Update(msg)
+		m.inputs[i] = newModel
+		cmds = append(cmds, cmd)
+	}
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m *model) sizeInputs() {
+	for i := range m.inputs {
+		m.inputs[i].SetWidth(m.width / len(m.inputs))
+	}
+}
+
+func (m *model) updateKeybindings() {
+	m.keymap.add.SetEnabled(len(m.inputs) < maxInputs)
+	m.keymap.remove.SetEnabled(len(m.inputs) > minInputs)
+}
+
+func (m model) View() string {
+	help := m.help.ShortHelpView([]key.Binding{
+		m.keymap.next,
+		m.keymap.prev,
+		m.keymap.add,
+		m.keymap.remove,
+		m.keymap.quit,
+	})
+
+	var views []string
+	for i := range m.inputs {
+		views = append(views, m.inputs[i].View())
+	}
+
+	return lipgloss.JoinHorizontal(lipgloss.Top, views...) + "\n\n" + help
+}
+
+func main() {
+	if err := tea.NewProgram(newModel()).Start(); err != nil {
+		fmt.Println("Error while running program:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/stopwatch/main.go b/examples/stopwatch/main.go
new file mode 100644
index 0000000..e203327
--- /dev/null
+++ b/examples/stopwatch/main.go
@@ -0,0 +1,103 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/charmbracelet/bubbles/help"
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/stopwatch"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+type model struct {
+	stopwatch stopwatch.Model
+	keymap    keymap
+	help      help.Model
+	quitting  bool
+}
+
+type keymap struct {
+	start key.Binding
+	stop  key.Binding
+	reset key.Binding
+	quit  key.Binding
+}
+
+func (m model) Init() tea.Cmd {
+	return m.stopwatch.Init()
+}
+
+func (m model) View() string {
+	// Note: you could further customize the time output by getting the
+	// duration from m.stopwatch.Elapsed(), which returns a time.Duration, and
+	// skip m.stopwatch.View() altogether.
+	s := m.stopwatch.View() + "\n"
+	if !m.quitting {
+		s = "Elapsed: " + s
+		s += m.helpView()
+	}
+	return s
+}
+
+func (m model) helpView() string {
+	return "\n" + m.help.ShortHelpView([]key.Binding{
+		m.keymap.start,
+		m.keymap.stop,
+		m.keymap.reset,
+		m.keymap.quit,
+	})
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, m.keymap.quit):
+			m.quitting = true
+			return m, tea.Quit
+		case key.Matches(msg, m.keymap.reset):
+			return m, m.stopwatch.Reset()
+		case key.Matches(msg, m.keymap.start, m.keymap.stop):
+			m.keymap.stop.SetEnabled(!m.stopwatch.Running())
+			m.keymap.start.SetEnabled(m.stopwatch.Running())
+			return m, m.stopwatch.Toggle()
+		}
+	}
+	var cmd tea.Cmd
+	m.stopwatch, cmd = m.stopwatch.Update(msg)
+	return m, cmd
+}
+
+func main() {
+	m := model{
+		stopwatch: stopwatch.NewWithInterval(time.Millisecond),
+		keymap: keymap{
+			start: key.NewBinding(
+				key.WithKeys("s"),
+				key.WithHelp("s", "start"),
+			),
+			stop: key.NewBinding(
+				key.WithKeys("s"),
+				key.WithHelp("s", "stop"),
+			),
+			reset: key.NewBinding(
+				key.WithKeys("r"),
+				key.WithHelp("r", "reset"),
+			),
+			quit: key.NewBinding(
+				key.WithKeys("ctrl+c", "q"),
+				key.WithHelp("q", "quit"),
+			),
+		},
+		help: help.NewModel(),
+	}
+
+	m.keymap.start.SetEnabled(false)
+
+	if err := tea.NewProgram(m).Start(); err != nil {
+		fmt.Println("Oh no, it didn't work:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/textarea/main.go b/examples/textarea/main.go
new file mode 100644
index 0000000..3180bfc
--- /dev/null
+++ b/examples/textarea/main.go
@@ -0,0 +1,82 @@
+package main
+
+// A simple program demonstrating the text input component from the Bubbles
+// component library.
+
+import (
+	"fmt"
+	"log"
+
+	"github.com/charmbracelet/bubbles/textarea"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func main() {
+	p := tea.NewProgram(initialModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+type tickMsg struct{}
+type errMsg error
+
+type model struct {
+	textarea textarea.Model
+	err      error
+}
+
+func initialModel() model {
+	ti := textarea.New()
+	ti.Placeholder = "Once upon a time..."
+	ti.Focus()
+
+	return model{
+		textarea: ti,
+		err:      nil,
+	}
+}
+
+func (m model) Init() tea.Cmd {
+	return textarea.Blink
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	var cmd tea.Cmd
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.Type {
+		case tea.KeyEsc:
+			if m.textarea.Focused() {
+				m.textarea.Blur()
+			}
+		case tea.KeyCtrlC:
+			return m, tea.Quit
+		default:
+			if !m.textarea.Focused() {
+				cmd = m.textarea.Focus()
+				cmds = append(cmds, cmd)
+			}
+		}
+
+	// We handle errors just like any other message
+	case errMsg:
+		m.err = msg
+		return m, nil
+	}
+
+	m.textarea, cmd = m.textarea.Update(msg)
+	cmds = append(cmds, cmd)
+	return m, tea.Batch(cmds...)
+}
+
+func (m model) View() string {
+	return fmt.Sprintf(
+		"Tell me a story.\n\n%s\n\n%s",
+		m.textarea.View(),
+		"(ctrl+c to quit)",
+	) + "\n\n"
+}
diff --git a/examples/textinput/main.go b/examples/textinput/main.go
index 25617ae..eac1c8a 100644
--- a/examples/textinput/main.go
+++ b/examples/textinput/main.go
@@ -28,7 +28,7 @@ type model struct {
 }
 
 func initialModel() model {
-	ti := textinput.NewModel()
+	ti := textinput.New()
 	ti.Placeholder = "Pikachu"
 	ti.Focus()
 	ti.CharLimit = 156
diff --git a/examples/textinputs/main.go b/examples/textinputs/main.go
index 7e92416..1bd5594 100644
--- a/examples/textinputs/main.go
+++ b/examples/textinputs/main.go
@@ -38,7 +38,7 @@ func initialModel() model {
 
 	var t textinput.Model
 	for i := range m.inputs {
-		t = textinput.NewModel()
+		t = textinput.New()
 		t.CursorStyle = cursorStyle
 		t.CharLimit = 32
 
diff --git a/examples/timer/main.go b/examples/timer/main.go
new file mode 100644
index 0000000..bf3a2c9
--- /dev/null
+++ b/examples/timer/main.go
@@ -0,0 +1,122 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/charmbracelet/bubbles/help"
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/timer"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+const timeout = time.Second * 5
+
+type model struct {
+	timer    timer.Model
+	keymap   keymap
+	help     help.Model
+	quitting bool
+}
+
+type keymap struct {
+	start key.Binding
+	stop  key.Binding
+	reset key.Binding
+	quit  key.Binding
+}
+
+func (m model) Init() tea.Cmd {
+	return m.timer.Init()
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case timer.TickMsg:
+		var cmd tea.Cmd
+		m.timer, cmd = m.timer.Update(msg)
+		return m, cmd
+
+	case timer.StartStopMsg:
+		var cmd tea.Cmd
+		m.timer, cmd = m.timer.Update(msg)
+		m.keymap.stop.SetEnabled(m.timer.Running())
+		m.keymap.start.SetEnabled(!m.timer.Running())
+		return m, cmd
+
+	case timer.TimeoutMsg:
+		m.quitting = true
+		return m, tea.Quit
+
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, m.keymap.quit):
+			m.quitting = true
+			return m, tea.Quit
+		case key.Matches(msg, m.keymap.reset):
+			m.timer.Timeout = timeout
+		case key.Matches(msg, m.keymap.start, m.keymap.stop):
+			return m, m.timer.Toggle()
+		}
+	}
+
+	return m, nil
+}
+
+func (m model) helpView() string {
+	return "\n" + m.help.ShortHelpView([]key.Binding{
+		m.keymap.start,
+		m.keymap.stop,
+		m.keymap.reset,
+		m.keymap.quit,
+	})
+}
+
+func (m model) View() string {
+	// For a more detailed timer view you could read m.timer.Timeout to get
+	// the remaining time as a time.Duration and skip calling m.timer.View()
+	// entirely.
+	s := m.timer.View()
+
+	if m.timer.Timedout() {
+		s = "All done!"
+	}
+	s += "\n"
+	if !m.quitting {
+		s = "Exiting in " + s
+		s += m.helpView()
+	}
+	return s
+}
+
+func main() {
+	m := model{
+		timer: timer.NewWithInterval(timeout, time.Millisecond),
+		keymap: keymap{
+			start: key.NewBinding(
+				key.WithKeys("s"),
+				key.WithHelp("s", "start"),
+			),
+			stop: key.NewBinding(
+				key.WithKeys("s"),
+				key.WithHelp("s", "stop"),
+			),
+			reset: key.NewBinding(
+				key.WithKeys("r"),
+				key.WithHelp("r", "reset"),
+			),
+			quit: key.NewBinding(
+				key.WithKeys("q", "ctrl+c"),
+				key.WithHelp("q", "quit"),
+			),
+		},
+		help: help.NewModel(),
+	}
+	m.keymap.start.SetEnabled(false)
+
+	if err := tea.NewProgram(m).Start(); err != nil {
+		fmt.Println("Uh oh, we encountered an error:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/tui-daemon-combo/main.go b/examples/tui-daemon-combo/main.go
index 4b22c82..de2d61c 100644
--- a/examples/tui-daemon-combo/main.go
+++ b/examples/tui-daemon-combo/main.go
@@ -65,7 +65,7 @@ type model struct {
 func newModel() model {
 	const showLastResults = 5
 
-	sp := spinner.NewModel()
+	sp := spinner.New()
 	sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("206"))
 
 	return model{
diff --git a/examples/views/main.go b/examples/views/main.go
index 5b4ed92..1a934be 100644
--- a/examples/views/main.go
+++ b/examples/views/main.go
@@ -28,7 +28,7 @@ const (
 
 // General stuff for styling the view
 var (
-	term          = termenv.ColorProfile()
+	term          = termenv.EnvColorProfile()
 	keyword       = makeFgStyle("211")
 	subtle        = makeFgStyle("241")
 	progressEmpty = subtle(progressEmptyChar)
diff --git a/exec.go b/exec.go
new file mode 100644
index 0000000..d1d9d69
--- /dev/null
+++ b/exec.go
@@ -0,0 +1,129 @@
+package tea
+
+import (
+	"io"
+	"os"
+	"os/exec"
+)
+
+// execMsg is used internally to run an ExecCommand sent with Exec.
+type execMsg struct {
+	cmd ExecCommand
+	fn  ExecCallback
+}
+
+// Exec is used to perform arbitrary I/O in a blocking fashion, effectively
+// pausing the Program while execution is runnning and resuming it when
+// execution has completed.
+//
+// Most of the time you'll want to use ExecProcess, which runs an exec.Cmd.
+//
+// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
+func Exec(c ExecCommand, fn ExecCallback) Cmd {
+	return func() Msg {
+		return execMsg{cmd: c, fn: fn}
+	}
+}
+
+// ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively
+// pausing the Program while the command is running. After the *exec.Cmd exists
+// the Program resumes. It's useful for spawning other interactive applications
+// such as editors and shells from within a Program.
+//
+// To produce the command, pass an *exec.Cmd and a function which returns
+// a message containing the error which may have occurred when running the
+// ExecCommand.
+//
+//     type VimFinishedMsg struct { err error }
+//
+//     c := exec.Command("vim", "file.txt")
+//
+//     cmd := ExecProcess(c, func(err error) Msg {
+//         return VimFinishedMsg{err: error}
+//     })
+//
+// Or, if you don't care about errors, you could simply:
+//
+//     cmd := ExecProcess(exec.Command("vim", "file.txt"), nil)
+//
+// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
+func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd {
+	return Exec(wrapExecCommand(c), fn)
+}
+
+// ExecCallback is used when executing an *exec.Command to return a message
+// with an error, which may or may not be nil.
+type ExecCallback func(error) Msg
+
+// ExecCommand can be implemented to execute things in a blocking fashion in
+// the current terminal.
+type ExecCommand interface {
+	Run() error
+	SetStdin(io.Reader)
+	SetStdout(io.Writer)
+	SetStderr(io.Writer)
+}
+
+// wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand
+// interface so it can be used with Exec.
+func wrapExecCommand(c *exec.Cmd) ExecCommand {
+	return &osExecCommand{Cmd: c}
+}
+
+// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand
+// interface.
+type osExecCommand struct{ *exec.Cmd }
+
+// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader.
+func (c *osExecCommand) SetStdin(r io.Reader) {
+	// If unset, have the command use the same input as the terminal.
+	if c.Stdin == nil {
+		c.Stdin = r
+	}
+}
+
+// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer.
+func (c *osExecCommand) SetStdout(w io.Writer) {
+	// If unset, have the command use the same output as the terminal.
+	if c.Stdout == nil {
+		c.Stdout = w
+	}
+}
+
+// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer.
+func (c *osExecCommand) SetStderr(w io.Writer) {
+	// If unset, use stderr for the command's stderr
+	if c.Stderr == nil {
+		c.Stderr = w
+	}
+}
+
+// exec runs an ExecCommand and delivers the results to the program as a Msg.
+func (p *Program) exec(c ExecCommand, fn ExecCallback) {
+	if err := p.ReleaseTerminal(); err != nil {
+		// If we can't release input, abort.
+		if fn != nil {
+			go p.Send(fn(err))
+		}
+		return
+	}
+
+	c.SetStdin(p.input)
+	c.SetStdout(p.output)
+	c.SetStderr(os.Stderr)
+
+	// Execute system command.
+	if err := c.Run(); err != nil {
+		_ = p.RestoreTerminal() // also try to restore the terminal.
+		if fn != nil {
+			go p.Send(fn(err))
+		}
+		return
+	}
+
+	// Have the program re-capture input.
+	err := p.RestoreTerminal()
+	if fn != nil {
+		go p.Send(fn(err))
+	}
+}
diff --git a/go.mod b/go.mod
index 760bb34..666e061 100644
--- a/go.mod
+++ b/go.mod
@@ -3,10 +3,12 @@ module github.com/charmbracelet/bubbletea
 go 1.13
 
 require (
-	github.com/containerd/console v1.0.2
-	github.com/mattn/go-isatty v0.0.12
-	github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
-	github.com/muesli/termenv v0.8.1
-	golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c
-	golang.org/x/term v0.0.0-20210422114643-f5beecf764ed
+	github.com/containerd/console v1.0.3
+	github.com/mattn/go-isatty v0.0.14
+	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b
+	github.com/muesli/cancelreader v0.2.1
+	github.com/muesli/reflow v0.3.0
+	github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
+	golang.org/x/sys v0.0.0-20220209214540-3681064d5158
+	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
 )
diff --git a/go.sum b/go.sum
index f20a630..c7b2a3c 100644
--- a/go.sum
+++ b/go.sum
@@ -1,23 +1,28 @@
-github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
-github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
-github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk=
-github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
-github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk=
-github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
+github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
diff --git a/key.go b/key.go
index 90e33cd..c210376 100644
--- a/key.go
+++ b/key.go
@@ -99,7 +99,7 @@ func (k KeyType) String() (str string) {
 	return ""
 }
 
-// Control keys. I know we could do this with an iota, but the values are very
+// Control keys. We could do this with an iota, but the values are very
 // specific, so we set the values explicitly to avoid any confusion.
 //
 // See also:
@@ -137,7 +137,6 @@ const (
 	keyGS  KeyType = 29  // group separator
 	keyRS  KeyType = 30  // record separator
 	keyUS  KeyType = 31  // unit separator
-	keySP  KeyType = 32  // space
 	keyDEL KeyType = 127 // delete. on most systems this is mapped to backspace, I hear
 )
 
@@ -148,7 +147,6 @@ const (
 	KeyEnter     KeyType = keyCR
 	KeyBackspace KeyType = keyDEL
 	KeyTab       KeyType = keyHT
-	KeySpace     KeyType = keySP
 	KeyEsc       KeyType = keyESC
 	KeyEscape    KeyType = keyESC
 
@@ -200,11 +198,45 @@ const (
 	KeyPgUp
 	KeyPgDown
 	KeyDelete
+	KeySpace
+	KeyCtrlUp
+	KeyCtrlDown
+	KeyCtrlRight
+	KeyCtrlLeft
+	KeyShiftUp
+	KeyShiftDown
+	KeyShiftRight
+	KeyShiftLeft
+	KeyCtrlShiftUp
+	KeyCtrlShiftDown
+	KeyCtrlShiftLeft
+	KeyCtrlShiftRight
+	KeyF1
+	KeyF2
+	KeyF3
+	KeyF4
+	KeyF5
+	KeyF6
+	KeyF7
+	KeyF8
+	KeyF9
+	KeyF10
+	KeyF11
+	KeyF12
+	KeyF13
+	KeyF14
+	KeyF15
+	KeyF16
+	KeyF17
+	KeyF18
+	KeyF19
+	KeyF20
 )
 
-// Mapping for control keys to friendly consts.
+// Mappings for control keys and other special keys to friendly consts.
 var keyNames = map[KeyType]string{
-	keyNUL: "ctrl+@", // also ctrl+`
+	// Control keys.
+	keyNUL: "ctrl+@", // also ctrl+` (that's ctrl+backtick)
 	keySOH: "ctrl+a",
 	keySTX: "ctrl+b",
 	keyETX: "ctrl+c",
@@ -236,54 +268,200 @@ var keyNames = map[KeyType]string{
 	keyGS:  "ctrl+]",
 	keyRS:  "ctrl+^",
 	keyUS:  "ctrl+_",
-	keySP:  "space",
 	keyDEL: "backspace",
 
-	KeyRunes:    "runes",
-	KeyUp:       "up",
-	KeyDown:     "down",
-	KeyRight:    "right",
-	KeyLeft:     "left",
-	KeyShiftTab: "shift+tab",
-	KeyHome:     "home",
-	KeyEnd:      "end",
-	KeyPgUp:     "pgup",
-	KeyPgDown:   "pgdown",
+	// Other keys.
+	KeyRunes:          "runes",
+	KeyUp:             "up",
+	KeyDown:           "down",
+	KeyRight:          "right",
+	KeySpace:          " ", // for backwards compatibility
+	KeyLeft:           "left",
+	KeyShiftTab:       "shift+tab",
+	KeyHome:           "home",
+	KeyEnd:            "end",
+	KeyPgUp:           "pgup",
+	KeyPgDown:         "pgdown",
+	KeyDelete:         "delete",
+	KeyCtrlUp:         "ctrl+up",
+	KeyCtrlDown:       "ctrl+down",
+	KeyCtrlRight:      "ctrl+right",
+	KeyCtrlLeft:       "ctrl+left",
+	KeyShiftUp:        "shift+up",
+	KeyShiftDown:      "shift+down",
+	KeyShiftRight:     "shift+right",
+	KeyShiftLeft:      "shift+left",
+	KeyCtrlShiftUp:    "ctrl+shift+up",
+	KeyCtrlShiftDown:  "ctrl+shift+down",
+	KeyCtrlShiftLeft:  "ctrl+shift+left",
+	KeyCtrlShiftRight: "ctrl+shift+right",
+	KeyF1:             "f1",
+	KeyF2:             "f2",
+	KeyF3:             "f3",
+	KeyF4:             "f4",
+	KeyF5:             "f5",
+	KeyF6:             "f6",
+	KeyF7:             "f7",
+	KeyF8:             "f8",
+	KeyF9:             "f9",
+	KeyF10:            "f10",
+	KeyF11:            "f11",
+	KeyF12:            "f12",
+	KeyF13:            "f13",
+	KeyF14:            "f14",
+	KeyF15:            "f15",
+	KeyF16:            "f16",
+	KeyF17:            "f17",
+	KeyF18:            "f18",
+	KeyF19:            "f19",
+	KeyF20:            "f20",
 }
 
-// Mapping for sequences to consts.
-var sequences = map[string]KeyType{
-	"\x1b[A": KeyUp,
-	"\x1b[B": KeyDown,
-	"\x1b[C": KeyRight,
-	"\x1b[D": KeyLeft,
+// Sequence mappings.
+var sequences = map[string]Key{
+	// Arrow keys
+	"\x1b[A":     {Type: KeyUp},
+	"\x1b[B":     {Type: KeyDown},
+	"\x1b[C":     {Type: KeyRight},
+	"\x1b[D":     {Type: KeyLeft},
+	"\x1b[1;2A":  {Type: KeyShiftUp},
+	"\x1b[1;2B":  {Type: KeyShiftDown},
+	"\x1b[1;2C":  {Type: KeyShiftRight},
+	"\x1b[1;2D":  {Type: KeyShiftLeft},
+	"\x1b[OA":    {Type: KeyShiftUp},    // DECCKM
+	"\x1b[OB":    {Type: KeyShiftDown},  // DECCKM
+	"\x1b[OC":    {Type: KeyShiftRight}, // DECCKM
+	"\x1b[OD":    {Type: KeyShiftLeft},  // DECCKM
+	"\x1b[a":     {Type: KeyShiftUp},    // urxvt
+	"\x1b[b":     {Type: KeyShiftDown},  // urxvt
+	"\x1b[c":     {Type: KeyShiftRight}, // urxvt
+	"\x1b[d":     {Type: KeyShiftLeft},  // urxvt
+	"\x1b[1;3A":  {Type: KeyUp, Alt: true},
+	"\x1b[1;3B":  {Type: KeyDown, Alt: true},
+	"\x1b[1;3C":  {Type: KeyRight, Alt: true},
+	"\x1b[1;3D":  {Type: KeyLeft, Alt: true},
+	"\x1b\x1b[A": {Type: KeyUp, Alt: true},    // urxvt
+	"\x1b\x1b[B": {Type: KeyDown, Alt: true},  // urxvt
+	"\x1b\x1b[C": {Type: KeyRight, Alt: true}, // urxvt
+	"\x1b\x1b[D": {Type: KeyLeft, Alt: true},  // urxvt
+	"\x1b[1;4A":  {Type: KeyShiftUp, Alt: true},
+	"\x1b[1;4B":  {Type: KeyShiftDown, Alt: true},
+	"\x1b[1;4C":  {Type: KeyShiftRight, Alt: true},
+	"\x1b[1;4D":  {Type: KeyShiftLeft, Alt: true},
+	"\x1b\x1b[a": {Type: KeyShiftUp, Alt: true},    // urxvt
+	"\x1b\x1b[b": {Type: KeyShiftDown, Alt: true},  // urxvt
+	"\x1b\x1b[c": {Type: KeyShiftRight, Alt: true}, // urxvt
+	"\x1b\x1b[d": {Type: KeyShiftLeft, Alt: true},  // urxvt
+	"\x1b[1;5A":  {Type: KeyCtrlUp},
+	"\x1b[1;5B":  {Type: KeyCtrlDown},
+	"\x1b[1;5C":  {Type: KeyCtrlRight},
+	"\x1b[1;5D":  {Type: KeyCtrlLeft},
+	"\x1b[Oa":    {Type: KeyCtrlUp, Alt: true},    // urxvt
+	"\x1b[Ob":    {Type: KeyCtrlDown, Alt: true},  // urxvt
+	"\x1b[Oc":    {Type: KeyCtrlRight, Alt: true}, // urxvt
+	"\x1b[Od":    {Type: KeyCtrlLeft, Alt: true},  // urxvt
+	"\x1b[1;6A":  {Type: KeyCtrlShiftUp},
+	"\x1b[1;6B":  {Type: KeyCtrlShiftDown},
+	"\x1b[1;6C":  {Type: KeyCtrlShiftRight},
+	"\x1b[1;6D":  {Type: KeyCtrlShiftLeft},
+	"\x1b[1;7A":  {Type: KeyCtrlUp, Alt: true},
+	"\x1b[1;7B":  {Type: KeyCtrlDown, Alt: true},
+	"\x1b[1;7C":  {Type: KeyCtrlRight, Alt: true},
+	"\x1b[1;7D":  {Type: KeyCtrlLeft, Alt: true},
+	"\x1b[1;8A":  {Type: KeyCtrlShiftUp, Alt: true},
+	"\x1b[1;8B":  {Type: KeyCtrlShiftDown, Alt: true},
+	"\x1b[1;8C":  {Type: KeyCtrlShiftRight, Alt: true},
+	"\x1b[1;8D":  {Type: KeyCtrlShiftLeft, Alt: true},
+
+	// Miscellaneous keys
+	"\x1b[Z":      {Type: KeyShiftTab},
+	"\x1b[3~":     {Type: KeyDelete},
+	"\x1b[3;3~":   {Type: KeyDelete, Alt: true},
+	"\x1b[1~":     {Type: KeyHome},
+	"\x1b[1;3H~":  {Type: KeyHome, Alt: true},
+	"\x1b[4~":     {Type: KeyEnd},
+	"\x1b[1;3F~":  {Type: KeyEnd, Alt: true},
+	"\x1b[5~":     {Type: KeyPgUp},
+	"\x1b[5;3~":   {Type: KeyPgUp, Alt: true},
+	"\x1b[6~":     {Type: KeyPgDown},
+	"\x1b[6;3~":   {Type: KeyPgDown, Alt: true},
+	"\x1b[7~":     {Type: KeyHome},              // urxvt
+	"\x1b[8~":     {Type: KeyEnd},               // urxvt
+	"\x1b\x1b[3~": {Type: KeyDelete, Alt: true}, // urxvt
+	"\x1b\x1b[5~": {Type: KeyPgUp, Alt: true},   // urxvt
+	"\x1b\x1b[6~": {Type: KeyPgDown, Alt: true}, // urxvt
+	"\x1b\x1b[7~": {Type: KeyHome, Alt: true},   // urxvt
+	"\x1b\x1b[8~": {Type: KeyEnd, Alt: true},    // urxvt
+
+	// Function keys, X11
+	"\x1bOP":     {Type: KeyF1},  // vt100
+	"\x1bOQ":     {Type: KeyF2},  // vt100
+	"\x1bOR":     {Type: KeyF3},  // vt100
+	"\x1bOS":     {Type: KeyF4},  // vt100
+	"\x1b[15~":   {Type: KeyF5},  // also urxvt
+	"\x1b[17~":   {Type: KeyF6},  // also urxvt
+	"\x1b[18~":   {Type: KeyF7},  // also urxvt
+	"\x1b[19~":   {Type: KeyF8},  // also urxvt
+	"\x1b[20~":   {Type: KeyF9},  // also urxvt
+	"\x1b[21~":   {Type: KeyF10}, // also urxvt
+	"\x1b[23~":   {Type: KeyF11}, // also urxvt
+	"\x1b[24~":   {Type: KeyF12}, // also urxvt
+	"\x1b[1;2P":  {Type: KeyF13},
+	"\x1b[1;2Q":  {Type: KeyF14},
+	"\x1b[1;2R":  {Type: KeyF15},
+	"\x1b[1;2S":  {Type: KeyF16},
+	"\x1b[15;2~": {Type: KeyF17},
+	"\x1b[17;2~": {Type: KeyF18},
+	"\x1b[18;2~": {Type: KeyF19},
+	"\x1b[19;2~": {Type: KeyF20},
+
+	// Function keys with the alt modifier, X11
+	"\x1b[1;3P":  {Type: KeyF1, Alt: true},
+	"\x1b[1;3Q":  {Type: KeyF2, Alt: true},
+	"\x1b[1;3R":  {Type: KeyF3, Alt: true},
+	"\x1b[1;3S":  {Type: KeyF4, Alt: true},
+	"\x1b[15;3~": {Type: KeyF5, Alt: true},
+	"\x1b[17;3~": {Type: KeyF6, Alt: true},
+	"\x1b[18;3~": {Type: KeyF7, Alt: true},
+	"\x1b[19;3~": {Type: KeyF8, Alt: true},
+	"\x1b[20;3~": {Type: KeyF9, Alt: true},
+	"\x1b[21;3~": {Type: KeyF10, Alt: true},
+	"\x1b[23;3~": {Type: KeyF11, Alt: true},
+	"\x1b[24;3~": {Type: KeyF12, Alt: true},
+
+	// Function keys, urxvt
+	"\x1b[11~": {Type: KeyF1},
+	"\x1b[12~": {Type: KeyF2},
+	"\x1b[13~": {Type: KeyF3},
+	"\x1b[14~": {Type: KeyF4},
+	"\x1b[25~": {Type: KeyF13},
+	"\x1b[26~": {Type: KeyF14},
+	"\x1b[28~": {Type: KeyF15},
+	"\x1b[29~": {Type: KeyF16},
+	"\x1b[31~": {Type: KeyF17},
+	"\x1b[32~": {Type: KeyF18},
+	"\x1b[33~": {Type: KeyF19},
+	"\x1b[34~": {Type: KeyF20},
+
+	// Function keys with the alt modifier, urxvt
+	"\x1b\x1b[11~": {Type: KeyF1, Alt: true},
+	"\x1b\x1b[12~": {Type: KeyF2, Alt: true},
+	"\x1b\x1b[13~": {Type: KeyF3, Alt: true},
+	"\x1b\x1b[14~": {Type: KeyF4, Alt: true},
+	"\x1b\x1b[25~": {Type: KeyF13, Alt: true},
+	"\x1b\x1b[26~": {Type: KeyF14, Alt: true},
+	"\x1b\x1b[28~": {Type: KeyF15, Alt: true},
+	"\x1b\x1b[29~": {Type: KeyF16, Alt: true},
+	"\x1b\x1b[31~": {Type: KeyF17, Alt: true},
+	"\x1b\x1b[32~": {Type: KeyF18, Alt: true},
+	"\x1b\x1b[33~": {Type: KeyF19, Alt: true},
+	"\x1b\x1b[34~": {Type: KeyF20, Alt: true},
 }
 
-// Mapping for hex codes to consts. Unclear why these won't register as
-// sequences.
+// Hex code mappings.
 var hexes = map[string]Key{
-	"1b5b5a":       {Type: KeyShiftTab},
-	"1b5b337e":     {Type: KeyDelete},
-	"1b0d":         {Type: KeyEnter, Alt: true},
-	"1b7f":         {Type: KeyBackspace, Alt: true},
-	"1b5b48":       {Type: KeyHome},
-	"1b5b377e":     {Type: KeyHome}, // urxvt
-	"1b5b313b3348": {Type: KeyHome, Alt: true},
-	"1b1b5b377e":   {Type: KeyHome, Alt: true}, // urxvt
-	"1b5b46":       {Type: KeyEnd},
-	"1b5b387e":     {Type: KeyEnd}, // urxvt
-	"1b5b313b3346": {Type: KeyEnd, Alt: true},
-	"1b1b5b387e":   {Type: KeyEnd, Alt: true}, // urxvt
-	"1b5b357e":     {Type: KeyPgUp},
-	"1b5b353b337e": {Type: KeyPgUp, Alt: true},
-	"1b1b5b357e":   {Type: KeyPgUp, Alt: true}, // urxvt
-	"1b5b367e":     {Type: KeyPgDown},
-	"1b5b363b337e": {Type: KeyPgDown, Alt: true},
-	"1b1b5b367e":   {Type: KeyPgDown, Alt: true}, // urxvt
-	"1b5b313b3341": {Type: KeyUp, Alt: true},
-	"1b5b313b3342": {Type: KeyDown, Alt: true},
-	"1b5b313b3343": {Type: KeyRight, Alt: true},
-	"1b5b313b3344": {Type: KeyLeft, Alt: true},
+	"1b0d": {Type: KeyEnter, Alt: true},
+	"1b7f": {Type: KeyBackspace, Alt: true},
 
 	// Powershell
 	"1b4f41": {Type: KeyUp, Alt: false},
@@ -292,9 +470,9 @@ var hexes = map[string]Key{
 	"1b4f44": {Type: KeyLeft, Alt: false},
 }
 
-// readInput reads keypress and mouse input from a TTY and returns a message
-// containing information about the key or mouse event accordingly.
-func readInput(input io.Reader) (Msg, error) {
+// readInputs reads keypress and mouse inputs from a TTY and returns messages
+// containing information about the key or mouse events accordingly.
+func readInputs(input io.Reader) ([]Msg, error) {
 	var buf [256]byte
 
 	// Read and block
@@ -303,36 +481,18 @@ func readInput(input io.Reader) (Msg, error) {
 		return nil, err
 	}
 
-	// See if it's a mouse event. For now we're parsing X10-type mouse events
+	// Check if it's a mouse event. For now we're parsing X10-type mouse events
 	// only.
-	mouseEvent, err := parseX10MouseEvent(buf[:numBytes])
+	mouseEvent, err := parseX10MouseEvents(buf[:numBytes])
 	if err == nil {
-		return MouseMsg(mouseEvent), nil
-	}
-
-	// Is it a special sequence, like an arrow key?
-	if k, ok := sequences[string(buf[:numBytes])]; ok {
-		return KeyMsg(Key{Type: k}), nil
-	}
-
-	// Some of these need special handling
-	hex := fmt.Sprintf("%x", buf[:numBytes])
-	if k, ok := hexes[hex]; ok {
-		return KeyMsg(k), nil
-	}
-
-	// Is the alt key pressed? The buffer will be prefixed with an escape
-	// sequence if so.
-	if numBytes > 1 && buf[0] == 0x1b {
-		// Now remove the initial escape sequence and re-process to get the
-		// character being pressed in combination with alt.
-		c, _ := utf8.DecodeRune(buf[1:])
-		if c == utf8.RuneError {
-			return nil, errors.New("could not decode rune after removing initial escape")
+		var m []Msg
+		for _, v := range mouseEvent {
+			m = append(m, MouseMsg(v))
 		}
-		return KeyMsg(Key{Alt: true, Type: KeyRunes, Runes: []rune{c}}), nil
+		return m, nil
 	}
 
+	var runeSets [][]rune
 	var runes []rune
 	b := buf[:numBytes]
 
@@ -344,24 +504,64 @@ func readInput(input io.Reader) (Msg, error) {
 		if r == utf8.RuneError {
 			return nil, errors.New("could not decode rune")
 		}
+
+		if r == '\x1b' && len(runes) > 1 {
+			// a new key sequence has started
+			runeSets = append(runeSets, runes)
+			runes = []rune{}
+		}
+
 		runes = append(runes, r)
 		w = width
 	}
+	// add the final set of runes we decoded
+	runeSets = append(runeSets, runes)
 
-	if len(runes) == 0 {
+	if len(runeSets) == 0 {
 		return nil, errors.New("received 0 runes from input")
-	} else if len(runes) > 1 {
-		// We received multiple runes, so we know this isn't a control
-		// character, sequence, and so on.
-		return KeyMsg(Key{Type: KeyRunes, Runes: runes}), nil
 	}
 
-	// Is the first rune a control character?
-	r := KeyType(runes[0])
-	if numBytes == 1 && r <= keyUS || r == keyDEL {
-		return KeyMsg(Key{Type: r}), nil
+	var msgs []Msg
+	for _, runes := range runeSets {
+		// Is it a sequence, like an arrow key?
+		if k, ok := sequences[string(runes)]; ok {
+			msgs = append(msgs, KeyMsg(k))
+			continue
+		}
+
+		// Some of these need special handling.
+		hex := fmt.Sprintf("%x", runes)
+		if k, ok := hexes[hex]; ok {
+			msgs = append(msgs, KeyMsg(k))
+			continue
+		}
+
+		// Is the alt key pressed? If so, the buffer will be prefixed with an
+		// escape.
+		if len(runes) > 1 && runes[0] == 0x1b {
+			msgs = append(msgs, KeyMsg(Key{Alt: true, Type: KeyRunes, Runes: runes[1:]}))
+			continue
+		}
+
+		for _, v := range runes {
+			// Is the first rune a control character?
+			r := KeyType(v)
+			if r <= keyUS || r == keyDEL {
+				msgs = append(msgs, KeyMsg(Key{Type: r}))
+				continue
+			}
+
+			// If it's a space, override the type with KeySpace (but still include
+			// the rune).
+			if r == ' ' {
+				msgs = append(msgs, KeyMsg(Key{Type: KeySpace, Runes: []rune{v}}))
+				continue
+			}
+
+			// Welp, just regular, ol' runes.
+			msgs = append(msgs, KeyMsg(Key{Type: KeyRunes, Runes: []rune{v}}))
+		}
 	}
 
-	// Welp, it's just a regular, ol' single rune
-	return KeyMsg(Key{Type: KeyRunes, Runes: runes}), nil
+	return msgs, nil
 }
diff --git a/mouse.go b/mouse.go
index 928fec8..5fb7427 100644
--- a/mouse.go
+++ b/mouse.go
@@ -1,7 +1,13 @@
 package tea
 
-import "errors"
+import (
+	"bytes"
+	"errors"
+)
 
+// MouseMsg contains information about a mouse event and are sent to a programs
+// update function when mouse activity occurs. Note that the mouse must first
+// be enabled via in order the mouse events to be received.
 type MouseMsg MouseEvent
 
 // MouseEvent represents a mouse event, which could be a click, a scroll wheel
@@ -29,6 +35,7 @@ func (m MouseEvent) String() (s string) {
 // MouseEventType indicates the type of mouse event occurring.
 type MouseEventType int
 
+// Mouse event types.
 const (
 	MouseUnknown MouseEventType = iota
 	MouseLeft
@@ -51,78 +58,92 @@ var mouseEventTypes = map[MouseEventType]string{
 	MouseMotion:    "motion",
 }
 
-// Parse an X10-encoded mouse event; the simplest kind. The last release of
-// X10 was December 1986, by the way.
+// Parse X10-encoded mouse events; the simplest kind. The last release of X10
+// was December 1986, by the way.
 //
 // X10 mouse events look like:
 //
 //     ESC [M Cb Cx Cy
 //
 // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
-func parseX10MouseEvent(buf []byte) (m MouseEvent, err error) {
-	if len(buf) != 6 || string(buf[:3]) != "\x1b[M" {
-		return m, errors.New("not an X10 mouse event")
-	}
-
-	const byteOffset = 32
-
-	e := buf[3] - byteOffset
+func parseX10MouseEvents(buf []byte) ([]MouseEvent, error) {
+	var r []MouseEvent
 
-	const (
-		bitShift  = 0b0000_0100
-		bitAlt    = 0b0000_1000
-		bitCtrl   = 0b0001_0000
-		bitMotion = 0b0010_0000
-		bitWheel  = 0b0100_0000
-
-		bitsMask = 0b0000_0011
+	seq := []byte("\x1b[M")
+	if !bytes.Contains(buf, seq) {
+		return r, errors.New("not an X10 mouse event")
+	}
 
-		bitsLeft    = 0b0000_0000
-		bitsMiddle  = 0b0000_0001
-		bitsRight   = 0b0000_0010
-		bitsRelease = 0b0000_0011
+	for _, v := range bytes.Split(buf, seq) {
+		if len(v) == 0 {
+			continue
+		}
+		if len(v) != 3 {
+			return r, errors.New("not an X10 mouse event")
+		}
 
-		bitsWheelUp   = 0b0000_0000
-		bitsWheelDown = 0b0000_0001
-	)
+		var m MouseEvent
+		const byteOffset = 32
+		e := v[0] - byteOffset
+
+		const (
+			bitShift  = 0b0000_0100
+			bitAlt    = 0b0000_1000
+			bitCtrl   = 0b0001_0000
+			bitMotion = 0b0010_0000
+			bitWheel  = 0b0100_0000
+
+			bitsMask = 0b0000_0011
+
+			bitsLeft    = 0b0000_0000
+			bitsMiddle  = 0b0000_0001
+			bitsRight   = 0b0000_0010
+			bitsRelease = 0b0000_0011
+
+			bitsWheelUp   = 0b0000_0000
+			bitsWheelDown = 0b0000_0001
+		)
+
+		if e&bitWheel != 0 {
+			// Check the low two bits.
+			switch e & bitsMask {
+			case bitsWheelUp:
+				m.Type = MouseWheelUp
+			case bitsWheelDown:
+				m.Type = MouseWheelDown
+			}
+		} else {
+			// Check the low two bits.
+			// We do not separate clicking and dragging.
+			switch e & bitsMask {
+			case bitsLeft:
+				m.Type = MouseLeft
+			case bitsMiddle:
+				m.Type = MouseMiddle
+			case bitsRight:
+				m.Type = MouseRight
+			case bitsRelease:
+				if e&bitMotion != 0 {
+					m.Type = MouseMotion
+				} else {
+					m.Type = MouseRelease
+				}
+			}
+		}
 
-	if e&bitWheel != 0 {
-		// Check the low two bits.
-		switch e & bitsMask {
-		case bitsWheelUp:
-			m.Type = MouseWheelUp
-		case bitsWheelDown:
-			m.Type = MouseWheelDown
+		if e&bitAlt != 0 {
+			m.Alt = true
 		}
-	} else {
-		// Check the low two bits.
-		// We do not separate clicking and dragging.
-		switch e & bitsMask {
-		case bitsLeft:
-			m.Type = MouseLeft
-		case bitsMiddle:
-			m.Type = MouseMiddle
-		case bitsRight:
-			m.Type = MouseRight
-		case bitsRelease:
-			if e&bitMotion != 0 {
-				m.Type = MouseMotion
-			} else {
-				m.Type = MouseRelease
-			}
+		if e&bitCtrl != 0 {
+			m.Ctrl = true
 		}
-	}
 
-	if e&bitAlt != 0 {
-		m.Alt = true
-	}
-	if e&bitCtrl != 0 {
-		m.Ctrl = true
-	}
+		// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
+		m.X = int(v[1]) - byteOffset - 1
+		m.Y = int(v[2]) - byteOffset - 1
 
-	// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
-	m.X = int(buf[4]) - byteOffset - 1
-	m.Y = int(buf[5]) - byteOffset - 1
+		r = append(r, m)
+	}
 
-	return m, nil
+	return r, nil
 }
diff --git a/options.go b/options.go
index 944c1e5..09e5ecb 100644
--- a/options.go
+++ b/options.go
@@ -119,3 +119,14 @@ func WithoutRenderer() ProgramOption {
 		m.renderer = &nilRenderer{}
 	}
 }
+
+// WithANSICompressor removes redundant ANSI sequences to produce potentially
+// smaller output, at the cost of some processing overhead.
+//
+// This feature is provisional, and may be changed removed in a future version
+// of this package.
+func WithANSICompressor() ProgramOption {
+	return func(p *Program) {
+		p.startupOptions |= withANSICompressor
+	}
+}
diff --git a/renderer.go b/renderer.go
index 1abe76d..aedbb66 100644
--- a/renderer.go
+++ b/renderer.go
@@ -11,17 +11,23 @@ type renderer interface {
 	// Stop the renderer without doing any final rendering.
 	kill()
 
-	// Write a frame to the renderer. The renderer can write this data to ouput
-	// at its discretion.
+	// Write a frame to the renderer. The renderer can write this data to
+	// output at its discretion.
 	write(string)
 
-	// Request a full re-render.
+	// Request a full re-render. Note that this will not trigger a render
+	// immediately. Rather, this method causes the next render to be a full
+	// repaint. Because of this, it's safe to call this method multiple times
+	// in succession.
 	repaint()
 
 	// Whether or not the alternate screen buffer is enabled.
 	altScreen() bool
 
-	// Record internally that the alternate screen buffer is enabled. This does
-	// should not actually toggle the alternate screen buffer.
+	// Record internally that the alternate screen buffer is enabled. This
+	// does not actually toggle the alternate screen buffer.
 	setAltScreen(bool)
 }
+
+// repaintMsg forces a full repaint.
+type repaintMsg struct{}
diff --git a/screen.go b/screen.go
index d27cbf0..63db7ae 100644
--- a/screen.go
+++ b/screen.go
@@ -42,3 +42,12 @@ func changeScrollingRegion(w io.Writer, top, bottom int) {
 func cursorBack(w io.Writer, n int) {
 	fmt.Fprintf(w, te.CSI+te.CursorBackSeq, n)
 }
+
+func enterAltScreen(w io.Writer) {
+	fmt.Fprintf(w, te.CSI+te.AltScreenSeq)
+	moveCursor(w, 0, 0)
+}
+
+func exitAltScreen(w io.Writer) {
+	fmt.Fprintf(w, te.CSI+te.ExitAltScreenSeq)
+}
diff --git a/signals_unix.go b/signals_unix.go
index 0ddb27d..c7ca029 100644
--- a/signals_unix.go
+++ b/signals_unix.go
@@ -1,8 +1,10 @@
+//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
 // +build darwin dragonfly freebsd linux netbsd openbsd solaris
 
 package tea
 
 import (
+	"context"
 	"os"
 	"os/signal"
 	"syscall"
@@ -13,15 +15,31 @@ import (
 // listenForResize sends messages (or errors) when the terminal resizes.
 // Argument output should be the file descriptor for the terminal; usually
 // os.Stdout.
-func listenForResize(output *os.File, msgs chan Msg, errs chan error) {
+func listenForResize(ctx context.Context, output *os.File, msgs chan Msg, errs chan error, done chan struct{}) {
 	sig := make(chan os.Signal, 1)
 	signal.Notify(sig, syscall.SIGWINCH)
+
+	defer func() {
+		signal.Stop(sig)
+		close(done)
+	}()
+
 	for {
-		<-sig
+		select {
+		case <-ctx.Done():
+			return
+		case <-sig:
+		}
+
 		w, h, err := term.GetSize(int(output.Fd()))
 		if err != nil {
 			errs <- err
 		}
-		msgs <- WindowSizeMsg{w, h}
+
+		select {
+		case <-ctx.Done():
+			return
+		case msgs <- WindowSizeMsg{w, h}:
+		}
 	}
 }
diff --git a/signals_windows.go b/signals_windows.go
index a71b12f..71da4f0 100644
--- a/signals_windows.go
+++ b/signals_windows.go
@@ -1,9 +1,16 @@
+//go:build windows
 // +build windows
 
 package tea
 
-import "os"
+import (
+	"context"
+	"os"
+)
 
 // listenForResize is not available on windows because windows does not
 // implement syscall.SIGWINCH.
-func listenForResize(_ *os.File, _ chan Msg, _ chan error) {}
+func listenForResize(ctx context.Context, output *os.File, msgs chan Msg,
+	errs chan error, done chan struct{}) {
+	close(done)
+}
diff --git a/standard_renderer.go b/standard_renderer.go
index 331eef6..2d12271 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -2,11 +2,13 @@ package tea
 
 import (
 	"bytes"
+	"fmt"
 	"io"
 	"strings"
 	"sync"
 	"time"
 
+	"github.com/muesli/ansi/compressor"
 	"github.com/muesli/reflow/truncate"
 )
 
@@ -22,14 +24,17 @@ const (
 // In cases where very high performance is needed the renderer can be told
 // to exclude ranges of lines, allowing them to be written to directly.
 type standardRenderer struct {
-	out           io.Writer
-	buf           bytes.Buffer
-	framerate     time.Duration
-	ticker        *time.Ticker
-	mtx           *sync.Mutex
-	done          chan struct{}
-	lastRender    string
-	linesRendered int
+	out                io.Writer
+	buf                bytes.Buffer
+	queuedMessageLines []string
+	framerate          time.Duration
+	ticker             *time.Ticker
+	mtx                *sync.Mutex
+	done               chan struct{}
+	lastRender         string
+	linesRendered      int
+	useANSICompressor  bool
+	once               sync.Once
 
 	// essentially whether or not we're using the full size of the terminal
 	altScreenActive bool
@@ -44,12 +49,18 @@ type standardRenderer struct {
 
 // newRenderer creates a new renderer. Normally you'll want to initialize it
 // with os.Stdout as the first argument.
-func newRenderer(out io.Writer, mtx *sync.Mutex) renderer {
-	return &standardRenderer{
-		out:       out,
-		mtx:       mtx,
-		framerate: defaultFramerate,
+func newRenderer(out io.Writer, mtx *sync.Mutex, useANSICompressor bool) renderer {
+	r := &standardRenderer{
+		out:                out,
+		mtx:                mtx,
+		framerate:          defaultFramerate,
+		useANSICompressor:  useANSICompressor,
+		queuedMessageLines: []string{},
 	}
+	if r.useANSICompressor {
+		r.out = &compressor.Writer{Forward: out}
+	}
+	return r
 }
 
 // start starts the renderer.
@@ -65,13 +76,23 @@ func (r *standardRenderer) start() {
 func (r *standardRenderer) stop() {
 	r.flush()
 	clearLine(r.out)
-	close(r.done)
+	r.once.Do(func() {
+		close(r.done)
+	})
+
+	if r.useANSICompressor {
+		if w, ok := r.out.(io.WriteCloser); ok {
+			_ = w.Close()
+		}
+	}
 }
 
 // kill halts the renderer. The final frame will not be rendered.
 func (r *standardRenderer) kill() {
 	clearLine(r.out)
-	close(r.done)
+	r.once.Do(func() {
+		close(r.done)
+	})
 }
 
 // listen waits for ticks on the ticker, or a signal to stop the renderer.
@@ -104,8 +125,16 @@ func (r *standardRenderer) flush() {
 	out := new(bytes.Buffer)
 
 	newLines := strings.Split(r.buf.String(), "\n")
+	numLinesThisFlush := len(newLines)
 	oldLines := strings.Split(r.lastRender, "\n")
 	skipLines := make(map[int]struct{})
+	flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive
+
+	// Add any queued messages to this render
+	if flushQueuedMessages {
+		newLines = append(r.queuedMessageLines, newLines...)
+		r.queuedMessageLines = []string{}
+	}
 
 	// Clear any lines we painted in the last render.
 	if r.linesRendered > 0 {
@@ -145,11 +174,9 @@ func (r *standardRenderer) flush() {
 		}
 	}
 
-	r.linesRendered = 0
-
 	// Paint new lines
 	for i := 0; i < len(newLines); i++ {
-		if _, skip := skipLines[r.linesRendered]; skip {
+		if _, skip := skipLines[i]; skip {
 			// Unless this is the last line, move the cursor down.
 			if i < len(newLines)-1 {
 				cursorDown(out)
@@ -174,8 +201,8 @@ func (r *standardRenderer) flush() {
 				_, _ = io.WriteString(out, "\r\n")
 			}
 		}
-		r.linesRendered++
 	}
+	r.linesRendered = numLinesThisFlush
 
 	// Make sure the cursor is at the start of the last line to keep rendering
 	// behavior consistent.
@@ -221,6 +248,7 @@ func (r *standardRenderer) altScreen() bool {
 
 func (r *standardRenderer) setAltScreen(v bool) {
 	r.altScreenActive = v
+	r.repaint()
 }
 
 // setIgnoredLines specifies lines not to be touched by the standard Bubble Tea
@@ -326,6 +354,13 @@ func (r *standardRenderer) insertBottom(lines []string, topBoundary, bottomBound
 // handleMessages handles internal messages for the renderer.
 func (r *standardRenderer) handleMessages(msg Msg) {
 	switch msg := msg.(type) {
+	case repaintMsg:
+		// Force a repaint by clearing the render cache as we slide into a
+		// render.
+		r.mtx.Lock()
+		r.repaint()
+		r.mtx.Unlock()
+
 	case WindowSizeMsg:
 		r.mtx.Lock()
 		r.width = msg.Width
@@ -338,7 +373,7 @@ func (r *standardRenderer) handleMessages(msg Msg) {
 		// Force a repaint on the area where the scrollable stuff was in this
 		// update cycle
 		r.mtx.Lock()
-		r.lastRender = ""
+		r.repaint()
 		r.mtx.Unlock()
 
 	case syncScrollAreaMsg:
@@ -349,7 +384,7 @@ func (r *standardRenderer) handleMessages(msg Msg) {
 
 		// Force non-scrolling stuff to repaint in this update cycle
 		r.mtx.Lock()
-		r.lastRender = ""
+		r.repaint()
 		r.mtx.Unlock()
 
 	case scrollUpMsg:
@@ -357,6 +392,15 @@ func (r *standardRenderer) handleMessages(msg Msg) {
 
 	case scrollDownMsg:
 		r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
+
+	case printLineMessage:
+		if !r.altScreenActive {
+			lines := strings.Split(msg.messageBody, "\n")
+			r.mtx.Lock()
+			r.queuedMessageLines = append(r.queuedMessageLines, lines...)
+			r.repaint()
+			r.mtx.Unlock()
+		}
 	}
 }
 
@@ -434,3 +478,38 @@ func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
 		}
 	}
 }
+
+type printLineMessage struct {
+	messageBody string
+}
+
+// Printf prints above the Program. This output is unmanaged by the program and
+// will persist across renders by the Program.
+//
+// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
+// its own line.
+//
+// If the altscreen is active no output will be printed.
+func Println(args ...interface{}) Cmd {
+	return func() Msg {
+		return printLineMessage{
+			messageBody: fmt.Sprint(args...),
+		}
+	}
+}
+
+// Printf prints above the Program. It takes a format template followed by
+// values similar to fmt.Printf. This output is unmanaged by the program and
+// will persist across renders by the Program.
+//
+// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
+// its own line.
+//
+// If the altscreen is active no output will be printed.
+func Printf(template string, args ...interface{}) Cmd {
+	return func() Msg {
+		return printLineMessage{
+			messageBody: fmt.Sprintf(template, args...),
+		}
+	}
+}
diff --git a/tea.go b/tea.go
index 9ca95bd..59b48f9 100644
--- a/tea.go
+++ b/tea.go
@@ -18,18 +18,20 @@ import (
 	"runtime/debug"
 	"sync"
 	"syscall"
+	"time"
 
 	"github.com/containerd/console"
 	isatty "github.com/mattn/go-isatty"
+	"github.com/muesli/cancelreader"
 	te "github.com/muesli/termenv"
 	"golang.org/x/term"
 )
 
-// Msg represents an action and is usually the result of an IO operation. It
-// triggers the Update function, and henceforth, the UI.
+// Msg contain data from the result of a IO operation. Msgs trigger the update
+// function and, henceforth, the UI.
 type Msg interface{}
 
-// Model contains the program's state as well as it's core functions.
+// Model contains the program's state as well as its core functions.
 type Model interface {
 	// Init is the first function that will be called. It returns an optional
 	// initial command. To not perform an initial command return nil.
@@ -44,12 +46,13 @@ type Model interface {
 	View() string
 }
 
-// Cmd is an IO operation. If it's nil it's considered a no-op. Use it for
-// things like HTTP requests, timers, saving and loading from disk, and so on.
+// Cmd is an IO operation that returns a message when it's complete. If it's
+// nil it's considered a no-op. Use it for things like HTTP requests, timers,
+// saving and loading from disk, and so on.
 //
-// There's almost never a need to use a command to send a message to another
-// part of your program. Instead, it can almost always be done in the update
-// function.
+// Note that there's almost never a reason to use a command to send a message
+// to another part of your program. That can almost always be done in the
+// update function.
 type Cmd func() Msg
 
 // Options to customize the program during its initialization. These are
@@ -68,6 +71,7 @@ const (
 	withMouseAllMotion
 	withInputTTY
 	withCustomInput
+	withANSICompressor
 )
 
 // Program is a terminal user interface.
@@ -78,15 +82,20 @@ type Program struct {
 	// treated as bits. These options can be set via various ProgramOptions.
 	startupOptions startupOptions
 
-	mtx  *sync.Mutex
-	done chan struct{}
+	ctx context.Context
+	mtx *sync.Mutex
 
-	msgs chan Msg
+	msgs         chan Msg
+	errs         chan error
+	readLoopDone chan struct{}
 
-	output          io.Writer // where to send output. this will usually be os.Stdout.
-	input           io.Reader // this will usually be os.Stdin.
-	renderer        renderer
-	altScreenActive bool
+	output       io.Writer // where to send output. this will usually be os.Stdout.
+	input        io.Reader // this will usually be os.Stdin.
+	cancelReader cancelreader.CancelReader
+
+	renderer           renderer
+	altScreenActive    bool
+	altScreenWasActive bool // was the altscreen active before releasing the terminal?
 
 	// CatchPanics is incredibly useful for restoring the terminal to a usable
 	// state after a panic occurs. When this is set, Bubble Tea will recover
@@ -94,6 +103,10 @@ type Program struct {
 	// is on by default.
 	CatchPanics bool
 
+	ignoreSignals bool
+
+	killc chan bool
+
 	console console.Console
 
 	// Stores the original reference to stdin for cases where input is not a
@@ -116,11 +129,18 @@ type Program struct {
 //     }
 //
 func Batch(cmds ...Cmd) Cmd {
-	if len(cmds) == 0 {
+	var validCmds []Cmd
+	for _, c := range cmds {
+		if c == nil {
+			continue
+		}
+		validCmds = append(validCmds, c)
+	}
+	if len(validCmds) == 0 {
 		return nil
 	}
 	return func() Msg {
-		return batchMsg(cmds)
+		return batchMsg(validCmds)
 	}
 }
 
@@ -138,7 +158,7 @@ func Quit() Msg {
 type quitMsg struct{}
 
 // EnterAltScreen is a special command that tells the Bubble Tea program to
-// enter alternate screen buffer.
+// enter the alternate screen buffer.
 //
 // Because commands run asynchronously, this command should not be used in your
 // model's Init function. To initialize your program with the altscreen enabled
@@ -236,10 +256,12 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 		initialModel: model,
 		output:       os.Stdout,
 		input:        os.Stdin,
+		msgs:         make(chan Msg),
 		CatchPanics:  true,
+		killc:        make(chan bool, 1),
 	}
 
-	// Apply all options to program
+	// Apply all options to the program.
 	for _, opt := range opts {
 		opt(p)
 	}
@@ -247,26 +269,49 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 	return p
 }
 
-// Start initializes the program.
-func (p *Program) Start() error {
-	p.msgs = make(chan Msg)
-	p.done = make(chan struct{})
+// StartReturningModel initializes the program. Returns the final model.
+func (p *Program) StartReturningModel() (Model, error) {
+	cmds := make(chan Cmd)
+	p.errs = make(chan error)
 
+	// Channels for managing goroutine lifecycles.
 	var (
-		cmds = make(chan Cmd)
-		errs = make(chan error)
+		sigintLoopDone = make(chan struct{})
+		cmdLoopDone    = make(chan struct{})
+		resizeLoopDone = make(chan struct{})
+		initSignalDone = make(chan struct{})
+
+		waitForGoroutines = func(withReadLoop bool) {
+			if withReadLoop {
+				select {
+				case <-p.readLoopDone:
+				case <-time.After(500 * time.Millisecond):
+					// The read loop hangs, which means the input
+					// cancelReader's cancel function has returned true even
+					// though it was not able to cancel the read.
+				}
+			}
+			<-cmdLoopDone
+			<-resizeLoopDone
+			<-sigintLoopDone
+			<-initSignalDone
+		}
 	)
 
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
+	var cancelContext context.CancelFunc
+	p.ctx, cancelContext = context.WithCancel(context.Background())
+	defer cancelContext()
 
 	switch {
 	case p.startupOptions.has(withInputTTY):
 		// Open a new TTY, by request
 		f, err := openInputTTY()
 		if err != nil {
-			return err
+			return p.initialModel, err
 		}
+
+		defer f.Close() // nolint:errcheck
+
 		p.input = f
 
 	case !p.startupOptions.has(withCustomInput):
@@ -285,8 +330,11 @@ func (p *Program) Start() error {
 
 		f, err := openInputTTY()
 		if err != nil {
-			return err
+			return p.initialModel, err
 		}
+
+		defer f.Close() // nolint:errcheck
+
 		p.input = f
 	}
 
@@ -297,12 +345,21 @@ func (p *Program) Start() error {
 	go func() {
 		sig := make(chan os.Signal, 1)
 		signal.Notify(sig, syscall.SIGINT)
-		defer signal.Stop(sig)
+		defer func() {
+			signal.Stop(sig)
+			close(sigintLoopDone)
+		}()
 
-		select {
-		case <-ctx.Done():
-		case <-sig:
-			p.msgs <- quitMsg{}
+		for {
+			select {
+			case <-p.ctx.Done():
+				return
+			case <-sig:
+				if !p.ignoreSignals {
+					p.msgs <- quitMsg{}
+					return
+				}
+			}
 		}
 	}()
 
@@ -320,12 +377,12 @@ func (p *Program) Start() error {
 	// Check if output is a TTY before entering raw mode, hiding the cursor and
 	// so on.
 	if err := p.initTerminal(); err != nil {
-		return err
+		return p.initialModel, err
 	}
 
 	// If no renderer is set use the standard one.
 	if p.renderer == nil {
-		p.renderer = newRenderer(p.output, p.mtx)
+		p.renderer = newRenderer(p.output, p.mtx, p.startupOptions.has(withANSICompressor))
 	}
 
 	// Honor program startup options.
@@ -338,82 +395,106 @@ func (p *Program) Start() error {
 		p.EnableMouseAllMotion()
 	}
 
-	// Initialize program
+	// Initialize the program.
 	model := p.initialModel
-	initCmd := model.Init()
-	if initCmd != nil {
+	if initCmd := model.Init(); initCmd != nil {
 		go func() {
-			cmds <- initCmd
+			defer close(initSignalDone)
+			select {
+			case cmds <- initCmd:
+			case <-p.ctx.Done():
+			}
 		}()
+	} else {
+		close(initSignalDone)
 	}
 
-	// Start renderer
+	// Start the renderer.
 	p.renderer.start()
 	p.renderer.setAltScreen(p.altScreenActive)
 
-	// Render initial view
+	// Render the initial view.
 	p.renderer.write(model.View())
 
-	// Subscribe to user input
+	// Subscribe to user input.
 	if p.input != nil {
-		go func() {
-			for {
-				msg, err := readInput(p.input)
-				if err != nil {
-					// If we get EOF just stop listening for input
-					if err == io.EOF {
-						break
-					}
-					errs <- err
-				}
-				p.msgs <- msg
-			}
-		}()
+		if err := p.initCancelReader(); err != nil {
+			return model, err
+		}
+	} else {
+		defer close(p.readLoopDone)
 	}
+	defer p.cancelReader.Close() // nolint:errcheck
 
-	if f, ok := p.output.(*os.File); ok {
-		// Get initial terminal size and send it to the program
+	if f, ok := p.output.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
+		// Get the initial terminal size and send it to the program.
 		go func() {
 			w, h, err := term.GetSize(int(f.Fd()))
 			if err != nil {
-				errs <- err
+				p.errs <- err
+			}
+
+			select {
+			case <-p.ctx.Done():
+			case p.msgs <- WindowSizeMsg{w, h}:
 			}
-			p.msgs <- WindowSizeMsg{w, h}
 		}()
 
-		// Listen for window resizes
-		go listenForResize(f, p.msgs, errs)
+		// Listen for window resizes.
+		go listenForResize(p.ctx, f, p.msgs, p.errs, resizeLoopDone)
+	} else {
+		close(resizeLoopDone)
 	}
 
-	// Process commands
+	// Process commands.
 	go func() {
+		defer close(cmdLoopDone)
+
 		for {
 			select {
-			case <-p.done:
+			case <-p.ctx.Done():
+
 				return
 			case cmd := <-cmds:
-				if cmd != nil {
-					go func() {
-						p.msgs <- cmd()
-					}()
+				if cmd == nil {
+					continue
 				}
+
+				// Don't wait on these goroutines, otherwise the shutdown
+				// latency would get too large as a Cmd can run for some time
+				// (e.g. tick commands that sleep for half a second). It's not
+				// possible to cancel them so we'll have to leak the goroutine
+				// until Cmd returns.
+				go func() {
+					select {
+					case p.msgs <- cmd():
+					case <-p.ctx.Done():
+					}
+				}()
 			}
 		}
 	}()
 
-	// Handle updates and draw
+	// Handle updates and draw.
 	for {
 		select {
-		case err := <-errs:
+		case <-p.killc:
+			return nil, nil
+		case err := <-p.errs:
+			cancelContext()
+			waitForGoroutines(p.cancelReader.Cancel())
 			p.shutdown(false)
-			return err
+			return model, err
+
 		case msg := <-p.msgs:
 
-			// Handle special internal messages
+			// Handle special internal messages.
 			switch msg := msg.(type) {
 			case quitMsg:
+				cancelContext()
+				waitForGoroutines(p.cancelReader.Cancel())
 				p.shutdown(false)
-				return nil
+				return model, nil
 
 			case batchMsg:
 				for _, cmd := range msg {
@@ -422,7 +503,9 @@ func (p *Program) Start() error {
 				continue
 
 			case WindowSizeMsg:
+				p.mtx.Lock()
 				p.renderer.repaint()
+				p.mtx.Unlock()
 
 			case enterAltScreenMsg:
 				p.EnterAltScreen()
@@ -442,9 +525,13 @@ func (p *Program) Start() error {
 
 			case hideCursorMsg:
 				hideCursor(p.output)
+
+			case execMsg:
+				// NB: this blocks.
+				p.exec(msg.cmd, msg.fn)
 			}
 
-			// Process internal messages for the renderer
+			// Process internal messages for the renderer.
 			if r, ok := p.renderer.(*standardRenderer); ok {
 				r.handleMessages(msg)
 			}
@@ -457,40 +544,60 @@ func (p *Program) Start() error {
 	}
 }
 
+// Start initializes the program. Ignores the final model.
+func (p *Program) Start() error {
+	_, err := p.StartReturningModel()
+	return err
+}
+
 // Send sends a message to the main update function, effectively allowing
 // messages to be injected from outside the program for interoperability
 // purposes.
 //
 // If the program is not running this this will be a no-op, so it's safe to
 // send messages if the program is unstarted, or has exited.
-//
-// This method is currently provisional. The method signature may alter
-// slightly, or it may be removed in a future version of this package.
 func (p *Program) Send(msg Msg) {
-	if p.msgs != nil {
-		p.msgs <- msg
-	}
+	p.msgs <- msg
+}
+
+// Quit is a convenience function for quitting Bubble Tea programs. Use it
+// when you need to shut down a Bubble Tea program from the outside.
+//
+// If you wish to quit from within a Bubble Tea program use the Quit command.
+//
+// If the program is not running this will be a no-op, so it's safe to call
+// if the program is unstarted or has already exited.
+func (p *Program) Quit() {
+	p.Send(Quit())
+}
+
+// Kill stops the program immediately and restores the former terminal state.
+// The final render that you would normally see when quitting will be skipped.
+func (p *Program) Kill() {
+	p.killc <- true
+	p.shutdown(true)
 }
 
 // shutdown performs operations to free up resources and restore the terminal
 // to its original state.
 func (p *Program) shutdown(kill bool) {
-	if kill {
-		p.renderer.kill()
-	} else {
-		p.renderer.stop()
+	if p.renderer != nil {
+		if kill {
+			p.renderer.kill()
+		} else {
+			p.renderer.stop()
+		}
 	}
-	close(p.done)
 	p.ExitAltScreen()
 	p.DisableMouseCellMotion()
 	p.DisableMouseAllMotion()
-	_ = p.restoreTerminal()
+	_ = p.restoreTerminalState()
 }
 
 // EnterAltScreen enters the alternate screen buffer, which consumes the entire
 // terminal window. ExitAltScreen will return the terminal to its former state.
 //
-// Deprecated. Use the WithAltScreen ProgramOption instead.
+// Deprecated: Use the WithAltScreen ProgramOption instead.
 func (p *Program) EnterAltScreen() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
@@ -499,8 +606,7 @@ func (p *Program) EnterAltScreen() {
 		return
 	}
 
-	fmt.Fprintf(p.output, te.CSI+te.AltScreenSeq)
-	moveCursor(p.output, 0, 0)
+	enterAltScreen(p.output)
 
 	p.altScreenActive = true
 	if p.renderer != nil {
@@ -510,7 +616,7 @@ func (p *Program) EnterAltScreen() {
 
 // ExitAltScreen exits the alternate screen buffer.
 //
-// Deprecated. The altscreen will exited automatically when the program exits.
+// Deprecated: The altscreen will exited automatically when the program exits.
 func (p *Program) ExitAltScreen() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
@@ -519,7 +625,7 @@ func (p *Program) ExitAltScreen() {
 		return
 	}
 
-	fmt.Fprintf(p.output, te.CSI+te.ExitAltScreenSeq)
+	exitAltScreen(p.output)
 
 	p.altScreenActive = false
 	if p.renderer != nil {
@@ -530,7 +636,7 @@ func (p *Program) ExitAltScreen() {
 // EnableMouseCellMotion enables mouse click, release, wheel and motion events
 // if a mouse button is pressed (i.e., drag events).
 //
-// Deprecated. Use the WithMouseCellMotion ProgramOption instead.
+// Deprecated: Use the WithMouseCellMotion ProgramOption instead.
 func (p *Program) EnableMouseCellMotion() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
@@ -540,7 +646,7 @@ func (p *Program) EnableMouseCellMotion() {
 // DisableMouseCellMotion disables Mouse Cell Motion tracking. This will be
 // called automatically when exiting a Bubble Tea program.
 //
-// Deprecated. The mouse will automatically be disabled when the program exits.
+// Deprecated: The mouse will automatically be disabled when the program exits.
 func (p *Program) DisableMouseCellMotion() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
@@ -551,7 +657,7 @@ func (p *Program) DisableMouseCellMotion() {
 // regardless of whether a mouse button is pressed. Many modern terminals
 // support this, but not all.
 //
-// Deprecated. Use the WithMouseAllMotion ProgramOption instead.
+// Deprecated: Use the WithMouseAllMotion ProgramOption instead.
 func (p *Program) EnableMouseAllMotion() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
@@ -561,9 +667,69 @@ func (p *Program) EnableMouseAllMotion() {
 // DisableMouseAllMotion disables All Motion mouse tracking. This will be
 // called automatically when exiting a Bubble Tea program.
 //
-// Deprecated. The mouse will automatically be disabled when the program exits.
+// Deprecated: The mouse will automatically be disabled when the program exits.
 func (p *Program) DisableMouseAllMotion() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
 	fmt.Fprintf(p.output, te.CSI+te.DisableMouseAllMotionSeq)
 }
+
+// ReleaseTerminal restores the original terminal state and cancels the input
+// reader. You can return control to the Program with RestoreTerminal.
+func (p *Program) ReleaseTerminal() error {
+	p.ignoreSignals = true
+	p.cancelInput()
+	p.altScreenWasActive = p.altScreenActive
+	if p.altScreenActive {
+		p.ExitAltScreen()
+		time.Sleep(time.Millisecond * 10) // give the terminal a moment to catch up
+	}
+	return p.restoreTerminalState()
+}
+
+// RestoreTerminal reinitializes the Program's input reader, restores the
+// terminal to the former state when the program was running, and repaints.
+// Use it to reinitialize a Program after running ReleaseTerminal.
+func (p *Program) RestoreTerminal() error {
+	p.ignoreSignals = false
+
+	if err := p.initTerminal(); err != nil {
+		return err
+	}
+
+	if err := p.initCancelReader(); err != nil {
+		return err
+	}
+
+	if p.altScreenWasActive {
+		p.EnterAltScreen()
+	}
+
+	go p.Send(repaintMsg{})
+
+	return nil
+}
+
+// Println prints above the Program. This output is unmanaged by the program
+// and will persist across renders by the Program.
+//
+// If the altscreen is active no output will be printed.
+func (p *Program) Println(args ...interface{}) {
+	p.msgs <- printLineMessage{
+		messageBody: fmt.Sprint(args...),
+	}
+}
+
+// Printf prints above the Program. It takes a format template followed by
+// values similar to fmt.Printf. This output is unmanaged by the program and
+// will persist across renders by the Program.
+//
+// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
+// its own line.
+//
+// If the altscreen is active no output will be printed.
+func (p *Program) Printf(template string, args ...interface{}) {
+	p.msgs <- printLineMessage{
+		messageBody: fmt.Sprintf(template, args...),
+	}
+}
diff --git a/tty.go b/tty.go
index c9006cc..17e508b 100644
--- a/tty.go
+++ b/tty.go
@@ -1,5 +1,12 @@
 package tea
 
+import (
+	"errors"
+	"io"
+
+	"github.com/muesli/cancelreader"
+)
+
 func (p *Program) initTerminal() error {
 	err := p.initInput()
 	if err != nil {
@@ -17,7 +24,9 @@ func (p *Program) initTerminal() error {
 	return nil
 }
 
-func (p Program) restoreTerminal() error {
+// restoreTerminalState restores the terminal to the state prior to running the
+// Bubble Tea program.
+func (p Program) restoreTerminalState() error {
 	showCursor(p.output)
 
 	if p.console != nil {
@@ -27,9 +36,45 @@ func (p Program) restoreTerminal() error {
 		}
 	}
 
-	if err := p.restoreInput(); err != nil {
+	return p.restoreInput()
+}
+
+// initCancelReader (re)commences reading inputs.
+func (p *Program) initCancelReader() error {
+	var err error
+	p.cancelReader, err = cancelreader.NewReader(p.input)
+	if err != nil {
 		return err
 	}
 
+	p.readLoopDone = make(chan struct{})
+	go func() {
+		defer close(p.readLoopDone)
+
+		for {
+			if p.ctx.Err() != nil {
+				return
+			}
+
+			msgs, err := readInputs(p.cancelReader)
+			if err != nil {
+				if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
+					p.errs <- err
+				}
+
+				return
+			}
+
+			for _, msg := range msgs {
+				p.msgs <- msg
+			}
+		}
+	}()
+
 	return nil
 }
+
+// cancelInput cancels the input reader.
+func (p *Program) cancelInput() {
+	p.cancelReader.Cancel()
+}
diff --git a/tty_unix.go b/tty_unix.go
index 12f88ac..74fe8df 100644
--- a/tty_unix.go
+++ b/tty_unix.go
@@ -1,3 +1,4 @@
+//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
 // +build darwin dragonfly freebsd linux netbsd openbsd solaris
 
 package tea
@@ -13,7 +14,7 @@ func (p *Program) initInput() error {
 	if f, ok := p.input.(*os.File); ok {
 		c, err := console.ConsoleFromFile(f)
 		if err != nil {
-			return nil
+			return nil //nolint:nilerr // ignore error, this was just a test
 		}
 		p.console = c
 	}
@@ -27,7 +28,7 @@ func (p *Program) initInput() error {
 // program exits.
 func (p *Program) restoreInput() error {
 	if p.console != nil {
-		return p.console.Close()
+		return p.console.Reset()
 	}
 	return nil
 }
diff --git a/tty_windows.go b/tty_windows.go
index ad432d5..9cfc7d9 100644
--- a/tty_windows.go
+++ b/tty_windows.go
@@ -1,3 +1,4 @@
+//go:build windows
 // +build windows
 
 package tea
diff --git a/tutorials/basics/README.md b/tutorials/basics/README.md
index be61e95..31bc382 100644
--- a/tutorials/basics/README.md
+++ b/tutorials/basics/README.md
@@ -2,7 +2,7 @@ Bubble Tea Basics
 =================
 
 Bubble Tea is based on the functional design paradigms of [The Elm
-Architecture][elm] which happens work nicely with Go. It's a delightful way to
+Architecture][elm], which happen to work nicely with Go. It's a delightful way to
 build applications.
 
 By the way, the non-annotated source code for this program is available
@@ -14,7 +14,7 @@ This tutorial assumes you have a working knowledge of Go.
 
 ## Enough! Let's get to it.
 
-For this tutorial we're making a to-do list.
+For this tutorial we're making a shopping list.
 
 To start we'll define our package and import some libraries. Our only external
 import will be the Bubble Tea library, which we'll call `tea` for short.
@@ -52,23 +52,29 @@ type model struct {
 
 ## Initialization
 
-Next we'll define our application’s initial state. We’ll store our initial
-model in a simple variable, and then define the `Init` method.  `Init` can
-return a `Cmd` that could perform some initial I/O. For now, we don't need to
-do any I/O, so for the command we'll just return `nil`, which translates to "no
-command."
+Next we’ll define our application’s initial state. In this case we’re defining
+a function to return our initial model, however we could just as easily define
+the initial model as a variable elsewhere, too.
 
 ```go
-var initialModel = model{
-    // Our to-do list is just a grocery list
-    choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
-
-    // A map which indicates which choices are selected. We're using
-    // the  map like a mathematical set. The keys refer to the indexes
-    // of the `choices` slice, above.
-    selected: make(map[int]struct{}),
+func initialModel() model {
+	return model{
+		// Our shopping list is a grocery list
+		choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
+
+		// A map which indicates which choices are selected. We're using
+		// the  map like a mathematical set. The keys refer to the indexes
+		// of the `choices` slice, above.
+		selected: make(map[int]struct{}),
+	}
 }
+```
+
+Next we define the `Init` method. `Init` can return a `Cmd` that could perform
+some initial I/O. For now, we don't need to do any I/O, so for the command
+we'll just return `nil`, which translates to "no command."
 
+```go
 func (m model) Init() tea.Cmd {
     // Just return `nil`, which means "no I/O right now, please."
     return nil
@@ -77,15 +83,15 @@ func (m model) Init() tea.Cmd {
 
 ## The Update Method
 
-Next we'll define the update method. The update function is called when
-"things happen." Its job is to look at what has happened and return an updated
-model in response to whatever happened. It can also return a `Cmd` and make
-more things happen, but for now don't worry about that part.
+Next up is the update method. The update function is called when ”things
+happen.” Its job is to look at what has happened and return an updated model in
+response. It can also return a `Cmd` to make more things happen, but for now
+don't worry about that part.
 
-In our case, when a user presses the down arrow, `update`'s job is to notice
+In our case, when a user presses the down arrow, `Update`’s job is to notice
 that the down arrow was pressed and move the cursor accordingly (or not).
 
-The "something happened" comes in the form of a `Msg`, which can be any type.
+The “something happened” comes in the form of a `Msg`, which can be any type.
 Messages are the result of some I/O that took place, such as a keypress, timer
 tick, or a response from a server.
 
@@ -139,18 +145,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 ```
 
-You may have noticed that "ctrl+c" and "q" above return a `tea.Quit` command
-with the model. That's a special command which instructs the Bubble Tea runtime
-to quit, exiting the program.
+You may have noticed that <kbd>ctrl+c</kbd> and <kbd>q</kbd> above return
+a `tea.Quit` command with the model. That’s a special command which instructs
+the Bubble Tea runtime to quit, exiting the program.
 
 ## The View Method
 
-At last, it's time to render our UI. Of all the methods, the view is the
+At last, it’s time to render our UI. Of all the methods, the view is the
 simplest. We look at the  model in it's current state and use it to return
 a `string`.  That string is our UI!
 
-Because the view describes the entire UI of your application, you don't have
-to worry about redraw logic and stuff like that. Bubble Tea takes care of it
+Because the view describes the entire UI of your application, you don’t have to
+worry about redrawing logic and stuff like that. Bubble Tea takes care of it
 for you.
 
 ```go
@@ -192,7 +198,7 @@ The last step is to simply run our program. We pass our initial model to
 
 ```go
 func main() {
-    p := tea.NewProgram(initialModel)
+    p := tea.NewProgram(initialModel())
     if err := p.Start(); err != nil {
         fmt.Printf("Alas, there's been an error: %v", err)
         os.Exit(1)
@@ -200,7 +206,7 @@ func main() {
 }
 ```
 
-## What's Next?
+## What’s Next?
 
 This tutorial covers the basics of building an interactive terminal UI, but
 in the real world you'll also need to perform I/O. To learn about that have a
@@ -213,22 +219,10 @@ there are [Go Docs][docs].
 [examples]: http://github.com/charmbracelet/bubbletea/tree/master/examples
 [docs]: https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc
 
-### Bubble Tea in the Wild
-
-For some Bubble Tea programs in production, see:
-
-* [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser and online markdown stash
-* [The Charm Tool](https://github.com/charmbracelet/charm): the Charm user account manager
-
-### Libraries we use with Bubble Tea
+## Additional Resources
 
-* [Bubbles][bubbles]: various Bubble Tea components
-* [Termenv][termenv]: Advanced ANSI styling for terminal applications
-* [Reflow][reflow]: ANSI-aware methods for formatting and generally working with text. Of particular note is `PrintableRuneWidth` in the `ansi` sub-package which measures the physical widths of strings. Many runes, such as East Asian characters, emojis, and various unicode symbols are two cells wide, so measuring a layout with `len()` often won't cut it. Reflow is particularly nice for this as it measures character widths while ignoring any ANSI sequences present.
-
-[termenv]: https://github.com/muesli/termenv
-[reflow]: https://github.com/muesli/reflow
-[bubbles]: https://github.com/charmbracelet/bubbles
+* [Libraries we use with Bubble Tea](https://github.com/charmbracelet/bubbletea/#libraries-we-use-with-bubble-tea)
+* [Bubble Tea in the Wild](https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild)
 
 ### Feedback
 
@@ -236,3 +230,11 @@ We'd love to hear your thoughts on this tutorial. Feel free to drop us a note!
 
 * [Twitter](https://twitter.com/charmcli)
 * [The Fediverse](https://mastodon.technology/@charm)
+
+***
+
+Part of [Charm](https://charm.sh).
+
+<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge-unrounded.jpg" width="400"></a>
+
+Charm热爱开源 • Charm loves open source
diff --git a/tutorials/basics/main.go b/tutorials/basics/main.go
index f3ec2ff..05d40b4 100644
--- a/tutorials/basics/main.go
+++ b/tutorials/basics/main.go
@@ -13,9 +13,15 @@ type model struct {
 	selected map[int]struct{}
 }
 
-var initialModel = model{
-	choices:  []string{"Carrots", "Celery", "Kohlrabi"},
-	selected: make(map[int]struct{}),
+func initialModel() model {
+	return model{
+		choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
+
+		// A map which indicates which choices are selected. We're using
+		// the  map like a mathematical set. The keys refer to the indexes
+		// of the `choices` slice, above.
+		selected: make(map[int]struct{}),
+	}
 }
 
 func (m model) Init() tea.Cmd {
@@ -72,7 +78,7 @@ func (m model) View() string {
 }
 
 func main() {
-	p := tea.NewProgram(initialModel)
+	p := tea.NewProgram(initialModel())
 	if err := p.Start(); err != nil {
 		fmt.Printf("Alas, there's been an error: %v", err)
 		os.Exit(1)
diff --git a/tutorials/commands/README.md b/tutorials/commands/README.md
index 6c505a0..cfeb639 100644
--- a/tutorials/commands/README.md
+++ b/tutorials/commands/README.md
@@ -7,8 +7,8 @@ understanding of [the first tutorial][basics].
 
 You can find the non-annotated version of this program [on GitHub][source].
 
-[basics]: http://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics
-[source]: https://github.com/charmbracelet/bubbletea/master/tutorials/commands
+[basics]: https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics
+[source]: https://github.com/charmbracelet/bubbletea/blob/master/tutorials/commands/main.go
 
 ## Let's Go!
 
@@ -82,7 +82,7 @@ func (e errMsg) Error() string { return e.err.Error() }
 ```
 
 And notice that we've defined two new `Msg` types. They can be any type, even
-an empty struct. We'll come back to them later later in our update function.
+an empty struct. We'll come back to them later in our update function.
 First, let's write our initialization function.
 
 ## The Initialization Method
@@ -183,56 +183,52 @@ And that's that. There's one more thing you that is helpful to know about
 
 `Cmd`s are defined in Bubble Tea as `type Cmd func() Msg`. So they're just
 functions that don't take any arguments and return a `Msg`, which can be
-anything. If you need to pass arguments to a command, you just make a function
+any type. If you need to pass arguments to a command, you just make a function
 that returns a command. For example:
 
+```go
+func cmdWithArg(id int) tea.Cmd {
+    return func() tea.Msg {
+        return someMsg{id: int}
+    }
+}
+```
+
+A more real-world example looks like:
+
 ```go
 func checkSomeUrl(url string) tea.Cmd {
     return func() tea.Msg {
         c := &http.Client{Timeout: 10 * time.Second}
         res, err := c.Get(url)
         if err != nil {
-            return errMsg(err)
+            return errMsg{err}
         }
         return statusMsg(res.StatusCode)
     }
 }
 ```
 
-Just make sure you do as much stuff as you can in the innermost function,
-because that's the one that runs asynchronously.
+Anyway, just make sure you do as much stuff as you can in the innermost
+function, because that's the one that runs asynchronously.
 
-## Anyway, Now What?
+## Now What?
 
 After doing this tutorial and [the previous one][basics] you should be ready to
-build a Bubble Tea program of your own.  We also recommend that you look at the
+build a Bubble Tea program of your own. We also recommend that you look at the
 Bubble Tea [example programs][examples] as well as [Bubbles][bubbles],
 a component library for Bubble Tea.
 
 And, of course, check out the [Go Docs][docs].
 
-### Bubble Tea in the Wild
-
-For some Bubble Tea programs in production, see:
-
-* [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser and online markdown stash
-* [The Charm Tool](https://github.com/charmbracelet/charm): the Charm user account manager
-
-[examples]: http://github.com/charmbracelet/bubbletea/tree/master/examples
-[docs]: https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc
 [bubbles]: https://github.com/charmbracelet/bubbles
+[docs]: https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc
+[examples]: https://github.com/charmbracelet/bubbletea/tree/master/examples
 
-### Libraries we use with Bubble Tea
-
-* [Bubbles][bubbles] various Bubble Tea components we've built
-* [Termenv][termenv]: Advanced ANSI styling for terminal applications
-* [Reflow][reflow]: ANSI-aware methods for reflowing blocks of text
-* [go-runewidth][runewidth]: Get the physical width of strings in terms of terminal cells. Many runes, such as East Asian charcters and emojis, are two cells wide, so measuring a layout with `len()` often won't cut it!
+## Additional Resources
 
-[termenv]: https://github.com/muesli/termenv
-[reflow]: https://github.com/muesli/reflow
-[bubbles]: https://github.com/charmbracelet/bubbles
-[runewidth]: https://github.com/mattn/go-runewidth
+* [Libraries we use with Bubble Tea](https://github.com/charmbracelet/bubbletea/#libraries-we-use-with-bubble-tea)
+* [Bubble Tea in the Wild](https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild)
 
 ### Feedback
 
@@ -240,3 +236,11 @@ We'd love to hear your thoughts on this tutorial. Feel free to drop us a note!
 
 * [Twitter](https://twitter.com/charmcli)
 * [The Fediverse](https://mastodon.technology/@charm)
+
+***
+
+Part of [Charm](https://charm.sh).
+
+<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge-unrounded.jpg" width="400"></a>
+
+Charm热爱开源 • Charm loves open source
diff --git a/tutorials/go.mod b/tutorials/go.mod
index 51b5f4e..6d00a45 100644
--- a/tutorials/go.mod
+++ b/tutorials/go.mod
@@ -2,6 +2,6 @@ module tutorial
 
 go 1.14
 
-require github.com/charmbracelet/bubbletea v0.14.1
+require github.com/charmbracelet/bubbletea v0.21.0
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/tutorials/go.sum b/tutorials/go.sum
index f20a630..c7b2a3c 100644
--- a/tutorials/go.sum
+++ b/tutorials/go.sum
@@ -1,23 +1,28 @@
-github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
-github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
-github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk=
-github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
-github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk=
-github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
+github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
-golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=


Test patch:
diff --git a/commands_test.go b/commands_test.go
index 2a11fbc..e0d5ff3 100644
--- a/commands_test.go
+++ b/commands_test.go
@@ -3,8 +3,29 @@ package tea
 import (
 	"fmt"
 	"testing"
+	"time"
 )
 
+func TestEvery(t *testing.T) {
+	expected := "every ms"
+	msg := Every(time.Millisecond, func(t time.Time) Msg {
+		return expected
+	})()
+	if expected != msg {
+		t.Fatalf("expected a msg %v but got %v", expected, msg)
+	}
+}
+
+func TestTick(t *testing.T) {
+	expected := "tick"
+	msg := Tick(time.Millisecond, func(t time.Time) Msg {
+		return expected
+	})()
+	if expected != msg {
+		t.Fatalf("expected a msg %v but got %v", expected, msg)
+	}
+}
+
 func TestSequentially(t *testing.T) {
 	var expectedErrMsg = fmt.Errorf("some err")
 	var expectedStrMsg = "some msg"
@@ -23,6 +44,11 @@ func TestSequentially(t *testing.T) {
 			cmds:     []Cmd{nilReturnCmd, nilReturnCmd},
 			expected: nil,
 		},
+		{
+			name:     "null cmds",
+			cmds:     []Cmd{nil, nil},
+			expected: nil,
+		},
 		{
 			name: "one error",
 			cmds: []Cmd{
@@ -54,3 +80,28 @@ func TestSequentially(t *testing.T) {
 		})
 	}
 }
+
+func TestBatch(t *testing.T) {
+	t.Run("nil cmd", func(t *testing.T) {
+		if b := Batch(nil); b != nil {
+			t.Fatalf("expected nil, got %+v", b)
+		}
+	})
+	t.Run("empty cmd", func(t *testing.T) {
+		if b := Batch(); b != nil {
+			t.Fatalf("expected nil, got %+v", b)
+		}
+	})
+	t.Run("single cmd", func(t *testing.T) {
+		b := Batch(Quit)()
+		if l := len(b.(batchMsg)); l != 1 {
+			t.Fatalf("expected a []Cmd with len 1, got %d", l)
+		}
+	})
+	t.Run("mixed nil cmds", func(t *testing.T) {
+		b := Batch(nil, Quit, nil, Quit, nil, nil)()
+		if l := len(b.(batchMsg)); l != 2 {
+			t.Fatalf("expected a []Cmd with len 2, got %d", l)
+		}
+	})
+}
diff --git a/key_test.go b/key_test.go
new file mode 100644
index 0000000..b74f06b
--- /dev/null
+++ b/key_test.go
@@ -0,0 +1,167 @@
+package tea
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestKeyString(t *testing.T) {
+	t.Run("alt+space", func(t *testing.T) {
+		if got := KeyMsg(Key{
+			Type: KeySpace,
+			Alt:  true,
+		}).String(); got != "alt+ " {
+			t.Fatalf(`expected a "alt+ ", got %q`, got)
+		}
+	})
+
+	t.Run("runes", func(t *testing.T) {
+		if got := KeyMsg(Key{
+			Type:  KeyRunes,
+			Runes: []rune{'a'},
+		}).String(); got != "a" {
+			t.Fatalf(`expected an "a", got %q`, got)
+		}
+	})
+
+	t.Run("invalid", func(t *testing.T) {
+		if got := KeyMsg(Key{
+			Type: KeyType(99999),
+		}).String(); got != "" {
+			t.Fatalf(`expected a "", got %q`, got)
+		}
+	})
+}
+
+func TestKeyTypeString(t *testing.T) {
+	t.Run("space", func(t *testing.T) {
+		if got := KeySpace.String(); got != " " {
+			t.Fatalf(`expected a " ", got %q`, got)
+		}
+	})
+
+	t.Run("invalid", func(t *testing.T) {
+		if got := KeyType(99999).String(); got != "" {
+			t.Fatalf(`expected a "", got %q`, got)
+		}
+	})
+}
+
+func TestReadInput(t *testing.T) {
+	type test struct {
+		in  []byte
+		out []Msg
+	}
+	for out, td := range map[string]test{
+		"a": {
+			[]byte{'a'},
+			[]Msg{
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'a'},
+				},
+			},
+		},
+		" ": {
+			[]byte{' '},
+			[]Msg{
+				KeyMsg{
+					Type:  KeySpace,
+					Runes: []rune{' '},
+				},
+			},
+		},
+		"ctrl+a": {
+			[]byte{byte(keySOH)},
+			[]Msg{
+				KeyMsg{
+					Type: KeyCtrlA,
+				},
+			},
+		},
+		"alt+a": {
+			[]byte{byte(0x1b), 'a'},
+			[]Msg{
+				KeyMsg{
+					Type:  KeyRunes,
+					Alt:   true,
+					Runes: []rune{'a'},
+				},
+			},
+		},
+		"abcd": {
+			[]byte{'a', 'b', 'c', 'd'},
+			[]Msg{
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'a'},
+				},
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'b'},
+				},
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'c'},
+				},
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'d'},
+				},
+			},
+		},
+		"up": {
+			[]byte("\x1b[A"),
+			[]Msg{
+				KeyMsg{
+					Type: KeyUp,
+				},
+			},
+		},
+		"wheel up": {
+			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
+			[]Msg{
+				MouseMsg{
+					Type: MouseWheelUp,
+				},
+			},
+		},
+		"shift+tab": {
+			[]byte{'\x1b', '[', 'Z'},
+			[]Msg{
+				KeyMsg{
+					Type: KeyShiftTab,
+				},
+			},
+		},
+	} {
+		t.Run(out, func(t *testing.T) {
+			msgs, err := readInputs(bytes.NewReader(td.in))
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if len(msgs) != len(td.out) {
+				t.Fatalf("unexpected message list length")
+			}
+
+			if len(msgs) == 1 {
+				if m, ok := msgs[0].(KeyMsg); ok && m.String() != out {
+					t.Fatalf(`expected a keymsg %q, got %q`, out, m)
+				}
+			}
+
+			for i, v := range msgs {
+				if m, ok := v.(KeyMsg); ok &&
+					m.String() != td.out[i].(KeyMsg).String() {
+					t.Fatalf(`expected a keymsg %q, got %q`, td.out[i].(KeyMsg), m)
+				}
+				if m, ok := v.(MouseMsg); ok &&
+					(mouseEventTypes[m.Type] != out || m.Type != td.out[i].(MouseMsg).Type) {
+					t.Fatalf(`expected a mousemsg %q, got %q`,
+						out,
+						mouseEventTypes[td.out[i].(MouseMsg).Type])
+				}
+			}
+		})
+	}
+}
diff --git a/logging_test.go b/logging_test.go
new file mode 100644
index 0000000..9994d9b
--- /dev/null
+++ b/logging_test.go
@@ -0,0 +1,29 @@
+package tea
+
+import (
+	"log"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestLogToFile(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "log.txt")
+	prefix := "logprefix"
+	f, err := LogToFile(path, prefix)
+	if err != nil {
+		t.Error(err)
+	}
+	log.SetFlags(log.Lmsgprefix)
+	log.Println("some test log")
+	if err := f.Close(); err != nil {
+		t.Error(err)
+	}
+	out, err := os.ReadFile(path)
+	if err != nil {
+		t.Error(err)
+	}
+	if string(out) != prefix+" some test log\n" {
+		t.Fatalf("wrong log msg: %q", string(out))
+	}
+}
diff --git a/mouse_test.go b/mouse_test.go
index 9a4df8a..d9c108f 100644
--- a/mouse_test.go
+++ b/mouse_test.go
@@ -122,209 +122,268 @@ func TestParseX10MouseEvent(t *testing.T) {
 	tt := []struct {
 		name     string
 		buf      []byte
-		expected MouseEvent
+		expected []MouseEvent
 	}{
 		// Position.
 		{
 			name: "zero position",
 			buf:  encode(0b0010_0000, 0, 0),
-			expected: MouseEvent{
-				X:    0,
-				Y:    0,
-				Type: MouseLeft,
+			expected: []MouseEvent{
+				{
+					X:    0,
+					Y:    0,
+					Type: MouseLeft,
+				},
 			},
 		},
 		{
 			name: "max position",
 			buf:  encode(0b0010_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
-			expected: MouseEvent{
-				X:    222,
-				Y:    222,
-				Type: MouseLeft,
+			expected: []MouseEvent{
+				{
+					X:    222,
+					Y:    222,
+					Type: MouseLeft,
+				},
 			},
 		},
 		// Simple.
 		{
 			name: "left",
 			buf:  encode(0b0000_0000, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseLeft,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseLeft,
+				},
 			},
 		},
 		{
 			name: "left in motion",
 			buf:  encode(0b0010_0000, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseLeft,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseLeft,
+				},
 			},
 		},
 		{
 			name: "middle",
 			buf:  encode(0b0000_0001, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseMiddle,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseMiddle,
+				},
 			},
 		},
 		{
 			name: "middle in motion",
 			buf:  encode(0b0010_0001, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseMiddle,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseMiddle,
+				},
 			},
 		},
 		{
 			name: "right",
 			buf:  encode(0b0000_0010, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseRight,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseRight,
+				},
 			},
 		},
 		{
 			name: "right in motion",
 			buf:  encode(0b0010_0010, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseRight,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseRight,
+				},
 			},
 		},
 		{
 			name: "motion",
 			buf:  encode(0b0010_0011, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseMotion,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseMotion,
+				},
 			},
 		},
 		{
 			name: "wheel up",
 			buf:  encode(0b0100_0000, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseWheelUp,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseWheelUp,
+				},
 			},
 		},
 		{
 			name: "wheel down",
 			buf:  encode(0b0100_0001, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseWheelDown,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseWheelDown,
+				},
 			},
 		},
 		{
 			name: "release",
 			buf:  encode(0b0000_0011, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseRelease,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseRelease,
+				},
 			},
 		},
 		// Combinations.
 		{
 			name: "alt+right",
 			buf:  encode(0b0010_1010, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseRight,
-				Alt:  true,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseRight,
+					Alt:  true,
+				},
 			},
 		},
 		{
 			name: "ctrl+right",
 			buf:  encode(0b0011_0010, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseRight,
-				Ctrl: true,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseRight,
+					Ctrl: true,
+				},
 			},
 		},
 		{
 			name: "ctrl+alt+right",
 			buf:  encode(0b0011_1010, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseRight,
-				Alt:  true,
-				Ctrl: true,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseRight,
+					Alt:  true,
+					Ctrl: true,
+				},
 			},
 		},
 		{
 			name: "alt+wheel down",
 			buf:  encode(0b0100_1001, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseWheelDown,
-				Alt:  true,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseWheelDown,
+					Alt:  true,
+				},
 			},
 		},
 		{
 			name: "ctrl+wheel down",
 			buf:  encode(0b0101_0001, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseWheelDown,
-				Ctrl: true,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseWheelDown,
+					Ctrl: true,
+				},
 			},
 		},
 		{
 			name: "ctrl+alt+wheel down",
 			buf:  encode(0b0101_1001, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseWheelDown,
-				Alt:  true,
-				Ctrl: true,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseWheelDown,
+					Alt:  true,
+					Ctrl: true,
+				},
 			},
 		},
 		// Unknown.
 		{
 			name: "wheel with unknown bit",
 			buf:  encode(0b0100_0010, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseUnknown,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseUnknown,
+				},
 			},
 		},
 		{
 			name: "unknown with modifier",
 			buf:  encode(0b0100_1010, 32, 16),
-			expected: MouseEvent{
-				X:    32,
-				Y:    16,
-				Type: MouseUnknown,
-				Alt:  true,
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseUnknown,
+					Alt:  true,
+				},
 			},
 		},
 		// Overflow position.
 		{
 			name: "overflow position",
 			buf:  encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
-			expected: MouseEvent{
-				X:    -6,
-				Y:    -33,
-				Type: MouseLeft,
+			expected: []MouseEvent{
+				{
+					X:    -6,
+					Y:    -33,
+					Type: MouseLeft,
+				},
+			},
+		},
+		// Batched events.
+		{
+			name: "batched events",
+			buf:  append(encode(0b0010_0000, 32, 16), encode(0b0000_0011, 64, 32)...),
+			expected: []MouseEvent{
+				{
+					X:    32,
+					Y:    16,
+					Type: MouseLeft,
+				},
+				{
+					X:    64,
+					Y:    32,
+					Type: MouseRelease,
+				},
 			},
 		},
 	}
@@ -333,18 +392,20 @@ func TestParseX10MouseEvent(t *testing.T) {
 		tc := tt[i]
 
 		t.Run(tc.name, func(t *testing.T) {
-			actual, err := parseX10MouseEvent(tc.buf)
+			actual, err := parseX10MouseEvents(tc.buf)
 			if err != nil {
-				t.Fatalf("unexpected error: %v",
+				t.Fatalf("unexpected error for test: %v",
 					err,
 				)
 			}
 
-			if tc.expected != actual {
-				t.Fatalf("expected %#v but got %#v",
-					tc.expected,
-					actual,
-				)
+			for i := range tc.expected {
+				if tc.expected[i] != actual[i] {
+					t.Fatalf("expected %#v but got %#v",
+						tc.expected[i],
+						actual[i],
+					)
+				}
 			}
 		})
 	}
@@ -377,7 +438,7 @@ func TestParseX10MouseEvent_error(t *testing.T) {
 		tc := tt[i]
 
 		t.Run(tc.name, func(t *testing.T) {
-			_, err := parseX10MouseEvent(tc.buf)
+			_, err := parseX10MouseEvents(tc.buf)
 
 			if err == nil {
 				t.Fatalf("expected error but got nil")
diff --git a/nil_renderer_test.go b/nil_renderer_test.go
new file mode 100644
index 0000000..7364484
--- /dev/null
+++ b/nil_renderer_test.go
@@ -0,0 +1,16 @@
+package tea
+
+import "testing"
+
+func TestNilRenderer(t *testing.T) {
+	r := nilRenderer{}
+	r.start()
+	r.stop()
+	r.kill()
+	r.write("a")
+	r.repaint()
+	r.setAltScreen(true)
+	if r.altScreen() {
+		t.Errorf("altScreen should always return false")
+	}
+}
diff --git a/options_test.go b/options_test.go
new file mode 100644
index 0000000..1b77977
--- /dev/null
+++ b/options_test.go
@@ -0,0 +1,94 @@
+package tea
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestOptions(t *testing.T) {
+	t.Run("output", func(t *testing.T) {
+		var b bytes.Buffer
+		p := NewProgram(nil, WithOutput(&b))
+		if p.output != &b {
+			t.Errorf("expected output to custom, got %v", p.output)
+		}
+	})
+
+	t.Run("input", func(t *testing.T) {
+		var b bytes.Buffer
+		p := NewProgram(nil, WithInput(&b))
+		if p.input != &b {
+			t.Errorf("expected input to custom, got %v", p.input)
+		}
+		if p.startupOptions&withCustomInput == 0 {
+			t.Errorf("expected startup options to have custom input set, got %v", p.input)
+		}
+	})
+
+	t.Run("catch panics", func(t *testing.T) {
+		p := NewProgram(nil, WithoutCatchPanics())
+		if p.CatchPanics {
+			t.Errorf("catch panics should not have been set")
+		}
+	})
+
+	t.Run("renderer", func(t *testing.T) {
+		p := NewProgram(nil, WithoutRenderer())
+		switch p.renderer.(type) {
+		case *nilRenderer:
+			return
+		default:
+			t.Errorf("expected renderer to be a nilRenderer, got %v", p.renderer)
+		}
+	})
+
+	t.Run("startup options", func(t *testing.T) {
+		exercise := func(t *testing.T, opt ProgramOption, expect startupOptions) {
+			p := NewProgram(nil, opt)
+			if !p.startupOptions.has(expect) {
+				t.Errorf("expected startup options have %v, got %v", expect, p.startupOptions)
+			}
+		}
+
+		t.Run("input tty", func(t *testing.T) {
+			exercise(t, WithInputTTY(), withInputTTY)
+		})
+
+		t.Run("alt screen", func(t *testing.T) {
+			exercise(t, WithAltScreen(), withAltScreen)
+		})
+
+		t.Run("ansi compression", func(t *testing.T) {
+			exercise(t, WithANSICompressor(), withANSICompressor)
+		})
+
+		t.Run("mouse cell motion", func(t *testing.T) {
+			p := NewProgram(nil, WithMouseAllMotion(), WithMouseCellMotion())
+			if !p.startupOptions.has(withMouseCellMotion) {
+				t.Errorf("expected startup options have %v, got %v", withMouseCellMotion, p.startupOptions)
+			}
+			if p.startupOptions.has(withMouseAllMotion) {
+				t.Errorf("expected startup options not have %v, got %v", withMouseAllMotion, p.startupOptions)
+			}
+		})
+
+		t.Run("mouse all motion", func(t *testing.T) {
+			p := NewProgram(nil, WithMouseCellMotion(), WithMouseAllMotion())
+			if !p.startupOptions.has(withMouseAllMotion) {
+				t.Errorf("expected startup options have %v, got %v", withMouseAllMotion, p.startupOptions)
+			}
+			if p.startupOptions.has(withMouseCellMotion) {
+				t.Errorf("expected startup options not have %v, got %v", withMouseCellMotion, p.startupOptions)
+			}
+		})
+	})
+
+	t.Run("multiple", func(t *testing.T) {
+		p := NewProgram(nil, WithMouseAllMotion(), WithAltScreen(), WithInputTTY())
+		for _, opt := range []startupOptions{withMouseAllMotion, withAltScreen, withInputTTY} {
+			if !p.startupOptions.has(opt) {
+				t.Errorf("expected startup options have %v, got %v", opt, p.startupOptions)
+			}
+		}
+	})
+}
diff --git a/screen_test.go b/screen_test.go
new file mode 100644
index 0000000..7bfdf2e
--- /dev/null
+++ b/screen_test.go
@@ -0,0 +1,66 @@
+package tea
+
+import (
+	"bytes"
+	"io"
+	"testing"
+)
+
+func TestScreen(t *testing.T) {
+	exercise := func(t *testing.T, fn func(io.Writer), expect []byte) {
+		var w bytes.Buffer
+		fn(&w)
+		if !bytes.Equal(w.Bytes(), expect) {
+			t.Errorf("expected %q, got %q", expect, w.Bytes())
+		}
+	}
+
+	t.Run("change scrolling region", func(t *testing.T) {
+		exercise(t, func(w io.Writer) {
+			changeScrollingRegion(w, 16, 22)
+		}, []byte("\x1b[16;22r"))
+	})
+
+	t.Run("line", func(t *testing.T) {
+		t.Run("clear", func(t *testing.T) {
+			exercise(t, clearLine, []byte("\x1b[2K"))
+		})
+
+		t.Run("insert", func(t *testing.T) {
+			exercise(t, func(w io.Writer) {
+				insertLine(w, 12)
+			}, []byte("\x1b[12L"))
+		})
+	})
+
+	t.Run("cursor", func(t *testing.T) {
+		t.Run("hide", func(t *testing.T) {
+			exercise(t, hideCursor, []byte("\x1b[?25l"))
+		})
+
+		t.Run("show", func(t *testing.T) {
+			exercise(t, showCursor, []byte("\x1b[?25h"))
+		})
+
+		t.Run("up", func(t *testing.T) {
+			exercise(t, cursorUp, []byte("\x1b[1A"))
+		})
+
+		t.Run("down", func(t *testing.T) {
+			exercise(t, cursorDown, []byte("\x1b[1B"))
+		})
+
+		t.Run("move", func(t *testing.T) {
+			exercise(t, func(w io.Writer) {
+				moveCursor(w, 10, 20)
+			}, []byte("\x1b[10;20H"))
+		})
+
+		t.Run("back", func(t *testing.T) {
+			exercise(t, func(w io.Writer) {
+				cursorBack(w, 15)
+			}, []byte("\x1b[15D"))
+		})
+	})
+
+}