Repository: charmbracelet/bubbletea
Pull request #275 (2022-04-02 14:41:42)

Doc: Add `Noted` to README.md
Added a project I've been working on to find and preview notes, to the README file.

Noted shows one of many ways you can use `viewports` from `bubbles` with `bubbletea`.

There might be enough bubbletea in the wild at this point, but here's another one.


Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index e43381c..c622d76 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -20,6 +20,16 @@ updates:
     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:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2ea6573..5d086b2 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,14 +5,14 @@ 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 }}
 
@@ -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 09c963b..bcc6721 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -12,7 +12,7 @@ jobs:
       GO111MODULE: "on"
     steps:
       - name: Install Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ matrix.go-version }}
 
diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml
index baa878c..77348b9 100644
--- a/.github/workflows/lint-soft.yml
+++ b/.github/workflows/lint-soft.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Install Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ^1
 
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 855d23d..1129ece 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Install Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ^1
 
diff --git a/.gitignore b/.gitignore
index 0eac410..285b1b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ tutorials/basics/basics
 tutorials/commands/commands
 .idea
 coverage.txt
+README.md.*
diff --git a/README.md b/README.md
index 8f11bd5..d5c13ab 100644
--- a/README.md
+++ b/README.md
@@ -21,243 +21,34 @@ performance optimizations we’ve added along the way. Among those is a standard
 framerate-based renderer, a renderer for high-performance scrollable
 regions which works alongside the main renderer, and mouse support.
 
-To get started, see the tutorial below, the [examples][examples], the
-[docs][docs] and some common [resources](#libraries-we-use-with-bubble-tea).
+## Getting Started
 
-## By the way
+We recommend starting with the [basics tutorial][basics] followed by the
+[commands tutorial][commands], both of which should give you a good
+understanding of how things work.
 
-Be sure to check out [Bubbles][bubbles], a library of common UI components for Bubble Tea.
+There are a bunch of [examples][examples], too!
+
+[basics]: https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics
+[commands]: https://github.com/charmbracelet/bubbletea/tree/master/tutorials/commands
+[documentation]: https://github.com/charmbracelet/bubbletea/tree/master/docs
+[examples]: https://github.com/charmbracelet/bubbletea/tree/master/examples
+
+## Components
+
+For a bunch of basic user interface components check out [Bubbles][bubbles],
+the official Bubble Tea component library.
 
 <p>
     <a href="https://github.com/charmbracelet/bubbles"><img src="https://stuff.charm.sh/bubbles/bubbles-badge.png" width="174" alt="Bubbles Badge"></a>&nbsp;&nbsp;
     <a href="https://github.com/charmbracelet/bubbles"><img src="https://stuff.charm.sh/bubbles-examples/textinput.gif" width="400" alt="Text Input Example from Bubbles"></a>
 </p>
 
-* * *
-
-## Tutorial
-
-Bubble Tea is based on the functional design paradigms of [The Elm
-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
-[on GitHub](https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics).
-
-This tutorial assumes you have a working knowledge of Go.
-
-[elm]: https://guide.elm-lang.org/architecture/
-
-## Enough! Let's get to it.
-
-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.
-
-```go
-package main
-
-import (
-    "fmt"
-    "os"
-
-    tea "github.com/charmbracelet/bubbletea"
-)
-```
-
-Bubble Tea programs are comprised of a **model** that describes the application
-state and three simple methods on that model:
-
-* **Init**, a function that returns an initial command for the application to run.
-* **Update**, a function that handles incoming events and updates the model accordingly.
-* **View**, a function that renders the UI based on the data in the model.
-
-## The Model
-
-So let's start by defining our model which will store our application's state.
-It can be any type, but a `struct` usually makes the most sense.
-
-```go
-type model struct {
-    choices  []string           // items on the to-do list
-    cursor   int                // which to-do list item our cursor is pointing at
-    selected map[int]struct{}   // which to-do items are selected
-}
-```
-
-## Initialization
-
-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
-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
-}
-```
+## Debugging
 
-## The Update Method
+### Debugging with Delve
 
-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
-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.
-Messages are the result of some I/O that took place, such as a keypress, timer
-tick, or a response from a server.
-
-We usually figure out which type of `Msg` we received with a type switch, but
-you could also use a type assertion.
-
-For now, we'll just deal with `tea.KeyMsg` messages, which are automatically
-sent to the update function when keys are pressed.
-
-```go
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-    switch msg := msg.(type) {
-
-    // Is it a key press?
-    case tea.KeyMsg:
-
-        // Cool, what was the actual key pressed?
-        switch msg.String() {
-
-        // These keys should exit the program.
-        case "ctrl+c", "q":
-            return m, tea.Quit
-
-        // The "up" and "k" keys move the cursor up
-        case "up", "k":
-            if m.cursor > 0 {
-                m.cursor--
-            }
-
-        // The "down" and "j" keys move the cursor down
-        case "down", "j":
-            if m.cursor < len(m.choices)-1 {
-                m.cursor++
-            }
-
-        // The "enter" key and the spacebar (a literal space) toggle
-        // the selected state for the item that the cursor is pointing at.
-        case "enter", " ":
-            _, ok := m.selected[m.cursor]
-            if ok {
-                delete(m.selected, m.cursor)
-            } else {
-                m.selected[m.cursor] = struct{}{}
-            }
-        }
-    }
-
-    // Return the updated model to the Bubble Tea runtime for processing.
-    // Note that we're not returning a command.
-    return m, nil
-}
-```
-
-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
-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 redrawing logic and stuff like that. Bubble Tea takes care of it
-for you.
-
-```go
-func (m model) View() string {
-    // The header
-    s := "What should we buy at the market?\n\n"
-
-    // Iterate over our choices
-    for i, choice := range m.choices {
-
-        // Is the cursor pointing at this choice?
-        cursor := " " // no cursor
-        if m.cursor == i {
-            cursor = ">" // cursor!
-        }
-
-        // Is this choice selected?
-        checked := " " // not selected
-        if _, ok := m.selected[i]; ok {
-            checked = "x" // selected!
-        }
-
-        // Render the row
-        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
-    }
-
-    // The footer
-    s += "\nPress q to quit.\n"
-
-    // Send the UI for rendering
-    return s
-}
-```
-
-## All Together Now
-
-The last step is to simply run our program. We pass our initial model to
-`tea.NewProgram` and let it rip:
-
-```go
-func main() {
-    p := tea.NewProgram(initialModel())
-    if err := p.Start(); err != nil {
-        fmt.Printf("Alas, there's been an error: %v", err)
-        os.Exit(1)
-    }
-}
-```
-
-## 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
-look at the [Command Tutorial][cmd]. It's pretty simple.
-
-There are also several [Bubble Tea examples][examples] available and, of course,
-there are [Go Docs][docs].
-
-[cmd]: http://github.com/charmbracelet/bubbletea/tree/master/tutorials/commands/
-[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 of stdin and stdout, you’ll need to run
+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
@@ -272,17 +63,38 @@ $ 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.
 
+### Logging Stuff
+
+You can log to a debug file to print debug Bubble Tea applications. To do so,
+include something like…
+
+```go
+if len(os.Getenv("DEBUG")) > 0 {
+	f, err := tea.LogToFile("debug.log", "debug")
+	if err != nil {
+		fmt.Println("fatal:", err)
+		os.Exit(1)
+	}
+	defer f.Close()
+}
+```
+
+…before you start your Bubble Tea program. To see what’s printed in real time,
+run `tail -f debug.log` while you run your program in another window.
+
 ## Libraries we use with Bubble Tea
 
 * [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on
 * [Lip Gloss][lipgloss]: Style, format and layout tools for terminal applications
 * [Harmonica][harmonica]: A spring animation library for smooth, natural motion
+* [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components
 * [Termenv][termenv]: Advanced ANSI styling for terminal applications
 * [Reflow][reflow]: Advanced ANSI-aware methods for working with text
 
 [bubbles]: https://github.com/charmbracelet/bubbles
 [lipgloss]: https://github.com/charmbracelet/lipgloss
 [harmonica]: https://github.com/charmbracelet/harmonica
+[bubblezone]: https://github.com/lrstanley/bubblezone
 [termenv]: https://github.com/muesli/termenv
 [reflow]: https://github.com/muesli/reflow
 
@@ -291,14 +103,21 @@ actually watch out what address the first `dlv` run tells you to connect to.
 For some Bubble Tea programs in production, see:
 
 * [AT CLI](https://github.com/daskycodes/at_cli): a utility for executing AT Commands via serial port connections
+* [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform
 * [Canard](https://github.com/mrusme/canard): an RSS client
-* [The Charm Tool](https://github.com/charmbracelet/charm): the Charm user account manager
+* [charm](https://github.com/charmbracelet/charm): the official Charm user account manager
+* [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in your terminal
 * [clidle](https://github.com/ajeetdsouza/clidle): a Wordle clone for your terminal
+* [container-canary](https://github.com/NVIDIA/container-canary): a container validator
+* [dns53](https://github.com/purpleclay/dns53): dynamic DNS with Amazon Route53. Expose your EC2 quickly, securely and privately
 * [fm](https://github.com/knipferrc/fm): a terminal-based file manager
+* [flapioca](https://github.com/kbrgl/flapioca): Flappy Bird on the CLI!
+* [fztea](https://github.com/jon4hz/fztea): connect to your Flipper's UI over serial or make it accessible via SSH 
 * [fork-cleaner](https://github.com/caarlos0/fork-cleaner): cleans up old and inactive forks in your GitHub account
 * [gambit](https://github.com/maaslalani/gambit): play chess in the terminal
 * [gembro](https://git.sr.ht/~rafael/gembro): a mouse-driven Gemini browser
-* [gh-prs](https://www.github.com/dlvhdr/gh-prs): gh cli extension to display a dashboard of PRs
+* [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
 * [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
@@ -307,8 +126,10 @@ For some Bubble Tea programs in production, see:
 * [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
+* [pathos](https://github.com/chip/pathos): pathos - CLI for editing a PATH env variable
 * [portal][portal]: securely send transfer between computers
 * [redis-viewer](https://github.com/SaltFishPr/redis-viewer): browse Redis databases
+* [sku](https://github.com/fedeztk/sku): a simple TUI for playing sudoku inside the terminal
 * [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
@@ -319,8 +140,10 @@ For some Bubble Tea programs in production, see:
 * [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
+* [ugm](https://github.com/ariasmn/ugm): a unix user and group browser
 * [Typer](https://github.com/maaslalani/typer): a typing test
 * [wishlist](https://github.com/charmbracelet/wishlist): an SSH directory
+* [Noted](https://github.com/torbratsberg/noted): Note viewer and manager
 
 [portal]: https://github.com/ZinoKader/portal
 
@@ -330,14 +153,18 @@ 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)
+* [Slack](https://charm.sh/slack)
 
 ## 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
 
diff --git a/cancelreader.go b/cancelreader.go
deleted file mode 100644
index 90b22f0..0000000
--- a/cancelreader.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package tea
-
-import (
-	"fmt"
-	"io"
-	"sync"
-)
-
-var errCanceled = fmt.Errorf("read cancelled")
-
-// cancelReader is a io.Reader whose Read() calls can be cancelled without data
-// being consumed. The cancelReader has to be closed.
-type cancelReader interface {
-	io.ReadCloser
-
-	// Cancel cancels ongoing and future reads an returns true if it succeeded.
-	Cancel() bool
-}
-
-// fallbackCancelReader implements cancelReader but does not actually support
-// cancelation during an ongoing Read() call. Thus, Cancel() always returns
-// false. However, after calling Cancel(), new Read() calls immediately return
-// errCanceled and don't consume any data anymore.
-type fallbackCancelReader struct {
-	r         io.Reader
-	cancelled bool
-}
-
-// newFallbackCancelReader is a fallback for newCancelReader that cannot
-// actually cancel an ongoing read but will immediately return on future reads
-// if it has been cancelled.
-func newFallbackCancelReader(reader io.Reader) (cancelReader, error) {
-	return &fallbackCancelReader{r: reader}, nil
-}
-
-func (r *fallbackCancelReader) Read(data []byte) (int, error) {
-	if r.cancelled {
-		return 0, errCanceled
-	}
-
-	return r.r.Read(data)
-}
-
-func (r *fallbackCancelReader) Cancel() bool {
-	r.cancelled = true
-
-	return false
-}
-
-func (r *fallbackCancelReader) Close() error {
-	return nil
-}
-
-// cancelMixin represents a goroutine-safe cancelation status.
-type cancelMixin struct {
-	unsafeCancelled bool
-	lock            sync.Mutex
-}
-
-func (c *cancelMixin) isCancelled() bool {
-	c.lock.Lock()
-	defer c.lock.Unlock()
-
-	return c.unsafeCancelled
-}
-
-func (c *cancelMixin) setCancelled() {
-	c.lock.Lock()
-	defer c.lock.Unlock()
-
-	c.unsafeCancelled = true
-}
diff --git a/cancelreader_bsd.go b/cancelreader_bsd.go
deleted file mode 100644
index e122fa2..0000000
--- a/cancelreader_bsd.go
+++ /dev/null
@@ -1,146 +0,0 @@
-//go:build darwin || freebsd || netbsd || openbsd || dragonfly
-// +build darwin freebsd netbsd openbsd dragonfly
-
-// nolint:revive
-package tea
-
-import (
-	"errors"
-	"fmt"
-	"io"
-	"os"
-	"strings"
-
-	"golang.org/x/sys/unix"
-)
-
-// newkqueueCancelReader returns a reader and a cancel function. If the input reader
-// is an *os.File, the cancel function can be used to interrupt a blocking call
-// read call. In this case, the cancel function returns true if the call was
-// cancelled successfully. If the input reader is not a *os.File, the cancel
-// function does nothing and always returns false. The BSD and macOS
-// implementation is based on the kqueue mechanism.
-func newCancelReader(reader io.Reader) (cancelReader, error) {
-	file, ok := reader.(*os.File)
-	if !ok {
-		return newFallbackCancelReader(reader)
-	}
-
-	// kqueue returns instantly when polling /dev/tty so fallback to select
-	if file.Name() == "/dev/tty" {
-		return newSelectCancelReader(reader)
-	}
-
-	kQueue, err := unix.Kqueue()
-	if err != nil {
-		return nil, fmt.Errorf("create kqueue: %w", err)
-	}
-
-	r := &kqueueCancelReader{
-		file:   file,
-		kQueue: kQueue,
-	}
-
-	r.cancelSignalReader, r.cancelSignalWriter, err = os.Pipe()
-	if err != nil {
-		return nil, err
-	}
-
-	unix.SetKevent(&r.kQueueEvents[0], int(file.Fd()), unix.EVFILT_READ, unix.EV_ADD)
-	unix.SetKevent(&r.kQueueEvents[1], int(r.cancelSignalReader.Fd()), unix.EVFILT_READ, unix.EV_ADD)
-
-	return r, nil
-}
-
-type kqueueCancelReader struct {
-	file               *os.File
-	cancelSignalReader *os.File
-	cancelSignalWriter *os.File
-	cancelMixin
-	kQueue       int
-	kQueueEvents [2]unix.Kevent_t
-}
-
-func (r *kqueueCancelReader) Read(data []byte) (int, error) {
-	if r.isCancelled() {
-		return 0, errCanceled
-	}
-
-	err := r.wait()
-	if err != nil {
-		if errors.Is(err, errCanceled) {
-			// remove signal from pipe
-			var b [1]byte
-			_, errRead := r.cancelSignalReader.Read(b[:])
-			if errRead != nil {
-				return 0, fmt.Errorf("reading cancel signal: %w", errRead)
-			}
-		}
-
-		return 0, err
-	}
-
-	return r.file.Read(data)
-}
-
-func (r *kqueueCancelReader) Cancel() bool {
-	r.setCancelled()
-
-	// send cancel signal
-	_, err := r.cancelSignalWriter.Write([]byte{'c'})
-	return err == nil
-}
-
-func (r *kqueueCancelReader) Close() error {
-	var errMsgs []string
-
-	// close kqueue
-	err := unix.Close(r.kQueue)
-	if err != nil {
-		errMsgs = append(errMsgs, fmt.Sprintf("closing kqueue: %v", err))
-	}
-
-	// close pipe
-	err = r.cancelSignalWriter.Close()
-	if err != nil {
-		errMsgs = append(errMsgs, fmt.Sprintf("closing cancel signal writer: %v", err))
-	}
-
-	err = r.cancelSignalReader.Close()
-	if err != nil {
-		errMsgs = append(errMsgs, fmt.Sprintf("closing cancel signal reader: %v", err))
-	}
-
-	if len(errMsgs) > 0 {
-		return fmt.Errorf(strings.Join(errMsgs, ", "))
-	}
-
-	return nil
-}
-
-func (r *kqueueCancelReader) wait() error {
-	events := make([]unix.Kevent_t, 1)
-
-	for {
-		_, err := unix.Kevent(r.kQueue, r.kQueueEvents[:], events, nil)
-		if errors.Is(err, unix.EINTR) {
-			continue // try again if the syscall was interrupted
-		}
-
-		if err != nil {
-			return fmt.Errorf("kevent: %w", err)
-		}
-
-		break
-	}
-
-	ident := uint64(events[0].Ident)
-	switch ident {
-	case uint64(r.file.Fd()):
-		return nil
-	case uint64(r.cancelSignalReader.Fd()):
-		return errCanceled
-	}
-
-	return fmt.Errorf("unknown error")
-}
diff --git a/cancelreader_default.go b/cancelreader_default.go
deleted file mode 100644
index eba557e..0000000
--- a/cancelreader_default.go
+++ /dev/null
@@ -1,14 +0,0 @@
-//go:build !darwin && !windows && !linux && !solaris && !freebsd && !netbsd && !openbsd && !dragonfly
-// +build !darwin,!windows,!linux,!solaris,!freebsd,!netbsd,!openbsd,!dragonfly
-
-package tea
-
-import (
-	"io"
-)
-
-// newCancelReader returns a fallbackCancelReader that satisfies the
-// cancelReader but does not actually support cancelation.
-func newCancelReader(reader io.Reader) (cancelReader, error) {
-	return newFallbackCancelReader(reader)
-}
diff --git a/cancelreader_linux.go b/cancelreader_linux.go
deleted file mode 100644
index 343f7d1..0000000
--- a/cancelreader_linux.go
+++ /dev/null
@@ -1,152 +0,0 @@
-//go:build linux
-// +build linux
-
-// nolint:revive
-package tea
-
-import (
-	"errors"
-	"fmt"
-	"io"
-	"os"
-	"strings"
-
-	"golang.org/x/sys/unix"
-)
-
-// newCancelReader returns a reader and a cancel function. If the input reader
-// is an *os.File, the cancel function can be used to interrupt a blocking call
-// read call. In this case, the cancel function returns true if the call was
-// cancelled successfully. If the input reader is not a *os.File, the cancel
-// function does nothing and always returns false. The linux implementation is
-// based on the epoll mechanism.
-func newCancelReader(reader io.Reader) (cancelReader, error) {
-	file, ok := reader.(*os.File)
-	if !ok {
-		return newFallbackCancelReader(reader)
-	}
-
-	epoll, err := unix.EpollCreate1(0)
-	if err != nil {
-		return nil, fmt.Errorf("create epoll: %w", err)
-	}
-
-	r := &epollCancelReader{
-		file:  file,
-		epoll: epoll,
-	}
-
-	r.cancelSignalReader, r.cancelSignalWriter, err = os.Pipe()
-	if err != nil {
-		return nil, err
-	}
-
-	err = unix.EpollCtl(epoll, unix.EPOLL_CTL_ADD, int(file.Fd()), &unix.EpollEvent{
-		Events: unix.EPOLLIN,
-		Fd:     int32(file.Fd()),
-	})
-	if err != nil {
-		return nil, fmt.Errorf("add reader to epoll interrest list")
-	}
-
-	err = unix.EpollCtl(epoll, unix.EPOLL_CTL_ADD, int(r.cancelSignalReader.Fd()), &unix.EpollEvent{
-		Events: unix.EPOLLIN,
-		Fd:     int32(r.cancelSignalReader.Fd()),
-	})
-	if err != nil {
-		return nil, fmt.Errorf("add reader to epoll interrest list")
-	}
-
-	return r, nil
-}
-
-type epollCancelReader struct {
-	file               *os.File
-	cancelSignalReader *os.File
-	cancelSignalWriter *os.File
-	cancelMixin
-	epoll int
-}
-
-func (r *epollCancelReader) Read(data []byte) (int, error) {
-	if r.isCancelled() {
-		return 0, errCanceled
-	}
-
-	err := r.wait()
-	if err != nil {
-		if errors.Is(err, errCanceled) {
-			// remove signal from pipe
-			var b [1]byte
-			_, readErr := r.cancelSignalReader.Read(b[:])
-			if readErr != nil {
-				return 0, fmt.Errorf("reading cancel signal: %w", readErr)
-			}
-		}
-
-		return 0, err
-	}
-
-	return r.file.Read(data)
-}
-
-func (r *epollCancelReader) Cancel() bool {
-	r.setCancelled()
-
-	// send cancel signal
-	_, err := r.cancelSignalWriter.Write([]byte{'c'})
-	return err == nil
-}
-
-func (r *epollCancelReader) Close() error {
-	var errMsgs []string
-
-	// close kqueue
-	err := unix.Close(r.epoll)
-	if err != nil {
-		errMsgs = append(errMsgs, fmt.Sprintf("closing epoll: %v", err))
-	}
-
-	// close pipe
-	err = r.cancelSignalWriter.Close()
-	if err != nil {
-		errMsgs = append(errMsgs, fmt.Sprintf("closing cancel signal writer: %v", err))
-	}
-
-	err = r.cancelSignalReader.Close()
-	if err != nil {
-		errMsgs = append(errMsgs, fmt.Sprintf("closing cancel signal reader: %v", err))
-	}
-
-	if len(errMsgs) > 0 {
-		return fmt.Errorf(strings.Join(errMsgs, ", "))
-	}
-
-	return nil
-}
-
-func (r *epollCancelReader) wait() error {
-	events := make([]unix.EpollEvent, 1)
-
-	for {
-		_, err := unix.EpollWait(r.epoll, events, -1)
-		if errors.Is(err, unix.EINTR) {
-			continue // try again if the syscall was interrupted
-		}
-
-		if err != nil {
-			return fmt.Errorf("kevent: %w", err)
-		}
-
-		break
-	}
-
-	switch events[0].Fd {
-	case int32(r.file.Fd()):
-		return nil
-	case int32(r.cancelSignalReader.Fd()):
-		return errCanceled
-	}
-
-	return fmt.Errorf("unknown error")
-}
diff --git a/cancelreader_select.go b/cancelreader_select.go
deleted file mode 100644
index 1749945..0000000
--- a/cancelreader_select.go
+++ /dev/null
@@ -1,137 +0,0 @@
-//go:build solaris || darwin || freebsd || netbsd || openbsd || dragonfly
-// +build solaris darwin freebsd netbsd openbsd dragonfly
-
-// nolint:revive
-package tea
-
-import (
-	"errors"
-	"fmt"
-	"io"
-	"os"
-	"strings"
-
-	"golang.org/x/sys/unix"
-)
-
-// newSelectCancelReader returns a reader and a cancel function. If the input
-// reader is an *os.File, the cancel function can be used to interrupt a
-// blocking call read call. In this case, the cancel function returns true if
-// the call was cancelled successfully. If the input reader is not a *os.File or
-// the file descriptor is 1024 or larger, the cancel function does nothing and
-// always returns false. The generic unix implementation is based on the posix
-// select syscall.
-func newSelectCancelReader(reader io.Reader) (cancelReader, error) {
-	file, ok := reader.(*os.File)
-	if !ok || file.Fd() >= unix.FD_SETSIZE {
-		return newFallbackCancelReader(reader)
-	}
-	r := &selectCancelReader{file: file}
-
-	var err error
-
-	r.cancelSignalReader, r.cancelSignalWriter, err = os.Pipe()
-	if err != nil {
-		return nil, err
-	}
-
-	return r, nil
-}
-
-type selectCancelReader struct {
-	file               *os.File
-	cancelSignalReader *os.File
-	cancelSignalWriter *os.File
-	cancelMixin
-}
-
-func (r *selectCancelReader) Read(data []byte) (int, error) {
-	if r.isCancelled() {
-		return 0, errCanceled
-	}
-
-	for {
-		err := waitForRead(r.file, r.cancelSignalReader)
-		if err != nil {
-			if errors.Is(err, unix.EINTR) {
-				continue // try again if the syscall was interrupted
-			}
-
-			if errors.Is(err, errCanceled) {
-				// remove signal from pipe
-				var b [1]byte
-				_, readErr := r.cancelSignalReader.Read(b[:])
-				if readErr != nil {
-					return 0, fmt.Errorf("reading cancel signal: %w", readErr)
-				}
-			}
-
-			return 0, err
-		}
-
-		return r.file.Read(data)
-	}
-}
-
-func (r *selectCancelReader) Cancel() bool {
-	r.setCancelled()
-
-	// send cancel signal
-	_, err := r.cancelSignalWriter.Write([]byte{'c'})
-	return err == nil
-}
-
-func (r *selectCancelReader) Close() error {
-	var errMsgs []string
-
-	// close pipe
-	err := r.cancelSignalWriter.Close()
-	if err != nil {
-		errMsgs = append(errMsgs, fmt.Sprintf("closing cancel signal writer: %v", err))
-	}
-
-	err = r.cancelSignalReader.Close()
-	if err != nil {
-		errMsgs = append(errMsgs, fmt.Sprintf("closing cancel signal reader: %v", err))
-	}
-
-	if len(errMsgs) > 0 {
-		return fmt.Errorf(strings.Join(errMsgs, ", "))
-	}
-
-	return nil
-}
-
-func waitForRead(reader *os.File, abort *os.File) error {
-	readerFd := int(reader.Fd())
-	abortFd := int(abort.Fd())
-
-	maxFd := readerFd
-	if abortFd > maxFd {
-		maxFd = abortFd
-	}
-
-	// this is a limitation of the select syscall
-	if maxFd >= unix.FD_SETSIZE {
-		return fmt.Errorf("cannot select on file descriptor %d which is larger than 1024", maxFd)
-	}
-
-	fdSet := &unix.FdSet{}
-	fdSet.Set(int(reader.Fd()))
-	fdSet.Set(int(abort.Fd()))
-
-	_, err := unix.Select(maxFd+1, fdSet, nil, nil, nil)
-	if err != nil {
-		return fmt.Errorf("select: %w", err)
-	}
-
-	if fdSet.IsSet(abortFd) {
-		return errCanceled
-	}
-
-	if fdSet.IsSet(readerFd) {
-		return nil
-	}
-
-	return fmt.Errorf("select returned without setting a file descriptor")
-}
diff --git a/cancelreader_unix.go b/cancelreader_unix.go
deleted file mode 100644
index c69db61..0000000
--- a/cancelreader_unix.go
+++ /dev/null
@@ -1,20 +0,0 @@
-//go:build solaris
-// +build solaris
-
-// nolint:revive
-package tea
-
-import (
-	"io"
-)
-
-// newCancelReader returns a reader and a cancel function. If the input reader
-// is an *os.File, the cancel function can be used to interrupt a blocking call
-// read call. In this case, the cancel function returns true if the call was
-// cancelled successfully. If the input reader is not a *os.File or the file
-// descriptor is 1024 or larger, the cancel function does nothing and always
-// returns false. The generic unix implementation is based on the posix select
-// syscall.
-func newCancelReader(reader io.Reader) (cancelReader, error) {
-	return newSelectCancelReader(reader)
-}
diff --git a/cancelreader_windows.go b/cancelreader_windows.go
deleted file mode 100644
index 0bd75d6..0000000
--- a/cancelreader_windows.go
+++ /dev/null
@@ -1,245 +0,0 @@
-//go:build windows
-// +build windows
-
-package tea
-
-import (
-	"fmt"
-	"io"
-	"os"
-	"syscall"
-	"time"
-	"unicode/utf16"
-
-	"golang.org/x/sys/windows"
-)
-
-var fileShareValidFlags uint32 = 0x00000007
-
-// newCancelReader returns a reader and a cancel function. If the input reader
-// is an *os.File with the same file descriptor as os.Stdin, the cancel function
-// can be used to interrupt a blocking call read call. In this case, the cancel
-// function returns true if the call was cancelled successfully. If the input
-// reader is not a *os.File with the same file descriptor as os.Stdin, the
-// cancel function does nothing and always returns false. The Windows
-// implementation is based on WaitForMultipleObject with overlapping reads from
-// CONIN$.
-func newCancelReader(reader io.Reader) (cancelReader, error) {
-	if f, ok := reader.(*os.File); !ok || f.Fd() != os.Stdin.Fd() {
-		return newFallbackCancelReader(reader)
-	}
-
-	// it is neccessary to open CONIN$ (NOT windows.STD_INPUT_HANDLE) in
-	// overlapped mode to be able to use it with WaitForMultipleObjects.
-	conin, err := windows.CreateFile(
-		&(utf16.Encode([]rune("CONIN$\x00"))[0]), windows.GENERIC_READ|windows.GENERIC_WRITE,
-		fileShareValidFlags, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OVERLAPPED, 0)
-	if err != nil {
-		return nil, fmt.Errorf("open CONIN$ in overlapping mode: %w", err)
-	}
-
-	resetConsole, err := prepareConsole(conin)
-	if err != nil {
-		return nil, fmt.Errorf("prepare console: %w", err)
-	}
-
-	// flush input, otherwise it can contain events which trigger
-	// WaitForMultipleObjects but which ReadFile cannot read, resulting in an
-	// un-cancelable read
-	err = flushConsoleInputBuffer(conin)
-	if err != nil {
-		return nil, fmt.Errorf("flush console input buffer: %w", err)
-	}
-
-	cancelEvent, err := windows.CreateEvent(nil, 0, 0, nil)
-	if err != nil {
-		return nil, fmt.Errorf("create stop event: %w", err)
-	}
-
-	return &winCancelReader{
-		conin:              conin,
-		cancelEvent:        cancelEvent,
-		resetConsole:       resetConsole,
-		blockingReadSignal: make(chan struct{}, 1),
-	}, nil
-}
-
-type winCancelReader struct {
-	conin       windows.Handle
-	cancelEvent windows.Handle
-	cancelMixin
-
-	resetConsole       func() error
-	blockingReadSignal chan struct{}
-}
-
-func (r *winCancelReader) Read(data []byte) (int, error) {
-	if r.isCancelled() {
-		return 0, errCanceled
-	}
-
-	err := r.wait()
-	if err != nil {
-		return 0, err
-	}
-
-	if r.isCancelled() {
-		return 0, errCanceled
-	}
-
-	// windows.Read does not work on overlapping windows.Handles
-	return r.readAsync(data)
-}
-
-// Cancel cancels ongoing and future Read() calls and returns true if the
-// cancelation of the ongoing Read() was successful. On Windows Terminal,
-// WaitForMultipleObjects sometimes immediately returns without input being
-// available. In this case, graceful cancelation is not possible and Cancel()
-// returns false.
-func (r *winCancelReader) Cancel() bool {
-	r.setCancelled()
-
-	select {
-	case r.blockingReadSignal <- struct{}{}:
-		err := windows.SetEvent(r.cancelEvent)
-		if err != nil {
-			return false
-		}
-		<-r.blockingReadSignal
-	case <-time.After(100 * time.Millisecond):
-		// Read() hangs in a GetOverlappedResult which is likely due to
-		// WaitForMultipleObjects returning without input being available
-		// so we cannot cancel this ongoing read.
-		return false
-	}
-
-	return true
-}
-
-func (r *winCancelReader) Close() error {
-	err := windows.CloseHandle(r.cancelEvent)
-	if err != nil {
-		return fmt.Errorf("closing cancel event handle: %w", err)
-	}
-
-	err = r.resetConsole()
-	if err != nil {
-		return err
-	}
-
-	err = windows.Close(r.conin)
-	if err != nil {
-		return fmt.Errorf("closing CONIN$")
-	}
-
-	return nil
-}
-
-func (r *winCancelReader) wait() error {
-	event, err := windows.WaitForMultipleObjects([]windows.Handle{r.conin, r.cancelEvent}, false, windows.INFINITE)
-	switch {
-	case windows.WAIT_OBJECT_0 <= event && event < windows.WAIT_OBJECT_0+2:
-		if event == windows.WAIT_OBJECT_0+1 {
-			return errCanceled
-		}
-
-		if event == windows.WAIT_OBJECT_0 {
-			return nil
-		}
-
-		return fmt.Errorf("unexpected wait object is ready: %d", event-windows.WAIT_OBJECT_0)
-	case windows.WAIT_ABANDONED <= event && event < windows.WAIT_ABANDONED+2:
-		return fmt.Errorf("abandoned")
-	case event == uint32(windows.WAIT_TIMEOUT):
-		return fmt.Errorf("timeout")
-	case event == windows.WAIT_FAILED:
-		return fmt.Errorf("failed")
-	default:
-		return fmt.Errorf("unexpected error: %w", error(err))
-	}
-}
-
-// readAsync is neccessary to read from a windows.Handle in overlapping mode.
-func (r *winCancelReader) readAsync(data []byte) (int, error) {
-	hevent, err := windows.CreateEvent(nil, 0, 0, nil)
-	if err != nil {
-		return 0, fmt.Errorf("create event: %w", err)
-	}
-
-	overlapped := windows.Overlapped{
-		HEvent: hevent,
-	}
-
-	var n uint32
-
-	err = windows.ReadFile(r.conin, data, &n, &overlapped)
-	if err != nil && err != windows.ERROR_IO_PENDING {
-		return int(n), err
-	}
-
-	r.blockingReadSignal <- struct{}{}
-	err = windows.GetOverlappedResult(r.conin, &overlapped, &n, true)
-	if err != nil {
-		return int(n), nil
-	}
-	<-r.blockingReadSignal
-
-	return int(n), nil
-}
-
-func prepareConsole(input windows.Handle) (reset func() error, err error) {
-	var originalMode uint32
-
-	err = windows.GetConsoleMode(input, &originalMode)
-	if err != nil {
-		return nil, fmt.Errorf("get console mode: %w", err)
-	}
-
-	var newMode uint32
-	newMode &^= windows.ENABLE_ECHO_INPUT
-	newMode &^= windows.ENABLE_LINE_INPUT
-	newMode &^= windows.ENABLE_MOUSE_INPUT
-	newMode &^= windows.ENABLE_WINDOW_INPUT
-	newMode &^= windows.ENABLE_PROCESSED_INPUT
-
-	newMode |= windows.ENABLE_EXTENDED_FLAGS
-	newMode |= windows.ENABLE_INSERT_MODE
-	newMode |= windows.ENABLE_QUICK_EDIT_MODE
-
-	// Enabling virutal terminal input is necessary for processing certain
-	// types of input like X10 mouse events and arrows keys with the current
-	// bytes-based input reader. It does, however, prevent cancelReader from
-	// being able to cancel input. The planned solution for this is to read
-	// Windows events in a more native fashion, rather than the current simple
-	// bytes-based input reader which works well on unix systems.
-	newMode |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT
-
-	err = windows.SetConsoleMode(input, newMode)
-	if err != nil {
-		return nil, fmt.Errorf("set console mode: %w", err)
-	}
-
-	return func() error {
-		err := windows.SetConsoleMode(input, originalMode)
-		if err != nil {
-			return fmt.Errorf("reset console mode: %w", err)
-		}
-
-		return nil
-	}, nil
-}
-
-var (
-	modkernel32                 = windows.NewLazySystemDLL("kernel32.dll")
-	procFlushConsoleInputBuffer = modkernel32.NewProc("FlushConsoleInputBuffer")
-)
-
-func flushConsoleInputBuffer(consoleInput windows.Handle) error {
-	r, _, e := syscall.Syscall(procFlushConsoleInputBuffer.Addr(), 1,
-		uintptr(consoleInput), 0, 0)
-	if r == 0 {
-		return error(e)
-	}
-
-	return nil
-}
diff --git a/commands.go b/commands.go
index 8aeab21..9111638 100644
--- a/commands.go
+++ b/commands.go
@@ -19,11 +19,40 @@ import (
 // To produce the command, pass a duration and a function which returns
 // a message containing the time at which the tick occurred.
 //
-//   type TickMsg time.Time
+//	type TickMsg time.Time
 //
-//   cmd := Every(time.Second, func(t time.Time) Msg {
-//      return TickMsg(t)
-//   })
+//	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()
@@ -40,11 +69,37 @@ func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
 // To produce the command, pass a duration and a function which returns
 // a message containing the time at which the tick occurred.
 //
-//   type TickMsg time.Time
+//	type TickMsg time.Time
 //
-//   cmd := Tick(time.Second, func(t time.Time) Msg {
-//      return TickMsg(t)
-//   })
+//	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)
@@ -56,15 +111,14 @@ func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
 // commands.
 // 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
-//   }
-//
-//   cmd := Sequentially(saveStateCmd, Quit)
+//	func saveStateCmd() Msg {
+//	   if err := save(); err != nil {
+//	       return errMsg{err}
+//	   }
+//	   return nil
+//	}
 //
+//	cmd := Sequentially(saveStateCmd, Quit)
 func Sequentially(cmds ...Cmd) Cmd {
 	return func() Msg {
 		for _, cmd := range cmds {
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/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/go.mod b/examples/go.mod
index b501b8b..833b579 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -3,15 +3,15 @@ module examples
 go 1.13
 
 require (
-	github.com/charmbracelet/bubbles v0.10.3
-	github.com/charmbracelet/bubbletea v0.19.3
+	github.com/charmbracelet/bubbles v0.13.0
+	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.14
+	github.com/mattn/go-isatty v0.0.16
 	github.com/muesli/reflow v0.3.0
-	github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
+	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 80b316b..4011628 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -2,15 +2,16 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbf
 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.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
-github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
+github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w=
+github.com/charmbracelet/bubbles v0.13.0/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.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
-github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
-github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
+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/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
@@ -29,8 +30,9 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+
 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.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-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 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=
@@ -40,13 +42,16 @@ github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY6
 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.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
 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=
@@ -68,13 +73,14 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GE
 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-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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 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-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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=
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/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/split-editors/main.go b/examples/split-editors/main.go
new file mode 100644
index 0000000..29b64ca
--- /dev/null
+++ b/examples/split-editors/main.go
@@ -0,0 +1,196 @@
+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 (
+	cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
+
+	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"))
+
+	focusedBorderStyle = lipgloss.NewStyle().
+				Border(lipgloss.RoundedBorder()).
+				BorderForeground(lipgloss.Color("238"))
+
+	blurredBorderStyle = lipgloss.NewStyle().
+				Border(lipgloss.HiddenBorder())
+)
+
+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 = cursorStyle
+	t.FocusedStyle.Placeholder = focusedPlaceholderStyle
+	t.BlurredStyle.Placeholder = placeholderStyle
+	t.FocusedStyle.CursorLine = cursorLineStyle
+	t.FocusedStyle.Base = focusedBorderStyle
+	t.BlurredStyle.Base = blurredBorderStyle
+	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/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/exec.go b/exec.go
new file mode 100644
index 0000000..587d91a
--- /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 878c510..8da123a 100644
--- a/go.mod
+++ b/go.mod
@@ -4,10 +4,11 @@ go 1.13
 
 require (
 	github.com/containerd/console v1.0.3
-	github.com/mattn/go-isatty v0.0.14
+	github.com/mattn/go-isatty v0.0.16
 	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b
+	github.com/muesli/cancelreader v0.2.2
 	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/sys v0.0.0-20220811171246-fbc7d0a398ab
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
 )
diff --git a/go.sum b/go.sum
index 80de060..7bb6610 100644
--- a/go.sum
+++ b/go.sum
@@ -2,13 +2,16 @@ github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARu
 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.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 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.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/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=
@@ -19,7 +22,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/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-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158/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-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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 a0ceea6..5c2dac1 100644
--- a/key.go
+++ b/key.go
@@ -11,30 +11,30 @@ import (
 // the program's update function. There are a couple general patterns you could
 // use to check for keypresses:
 //
-//     // Switch on the string representation of the key (shorter)
-//     switch msg := msg.(type) {
-//     case KeyMsg:
-//         switch msg.String() {
-//         case "enter":
-//             fmt.Println("you pressed enter!")
-//         case "a":
-//             fmt.Println("you pressed a!")
-//         }
-//     }
+//	// Switch on the string representation of the key (shorter)
+//	switch msg := msg.(type) {
+//	case KeyMsg:
+//	    switch msg.String() {
+//	    case "enter":
+//	        fmt.Println("you pressed enter!")
+//	    case "a":
+//	        fmt.Println("you pressed a!")
+//	    }
+//	}
 //
-//     // Switch on the key type (more foolproof)
-//     switch msg := msg.(type) {
-//     case KeyMsg:
-//         switch msg.Type {
-//         case KeyEnter:
-//             fmt.Println("you pressed enter!")
-//         case KeyRunes:
-//             switch string(msg.Runes) {
-//             case "a":
-//                 fmt.Println("you pressed a!")
-//             }
-//         }
-//     }
+//	// Switch on the key type (more foolproof)
+//	switch msg := msg.(type) {
+//	case KeyMsg:
+//	    switch msg.Type {
+//	    case KeyEnter:
+//	        fmt.Println("you pressed enter!")
+//	    case KeyRunes:
+//	        switch string(msg.Runes) {
+//	        case "a":
+//	            fmt.Println("you pressed a!")
+//	        }
+//	    }
+//	}
 //
 // Note that Key.Runes will always contain at least one character, so you can
 // always safely call Key.Runes[0]. In most cases Key.Runes will only contain
@@ -58,10 +58,9 @@ type Key struct {
 // String returns a friendly string representation for a key. It's safe (and
 // encouraged) for use in key comparison.
 //
-//     k := Key{Type: KeyEnter}
-//     fmt.Println(k)
-//     // Output: enter
-//
+//	k := Key{Type: KeyEnter}
+//	fmt.Println(k)
+//	// Output: enter
 func (k Key) String() (str string) {
 	if k.Alt {
 		str += "alt+"
@@ -80,16 +79,16 @@ func (k Key) String() (str string) {
 // All other keys will be type KeyRunes. To get the rune value, check the Rune
 // method on a Key struct, or use the Key.String() method:
 //
-//     k := Key{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}
-//     if k.Type == KeyRunes {
+//	k := Key{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}
+//	if k.Type == KeyRunes {
 //
-//         fmt.Println(k.Runes)
-//         // Output: a
+//	    fmt.Println(k.Runes)
+//	    // Output: a
 //
-//         fmt.Println(k.String())
-//         // Output: alt+a
+//	    fmt.Println(k.String())
+//	    // Output: alt+a
 //
-//     }
+//	}
 type KeyType int
 
 func (k KeyType) String() (str string) {
@@ -137,7 +136,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 +146,6 @@ const (
 	KeyEnter     KeyType = keyCR
 	KeyBackspace KeyType = keyDEL
 	KeyTab       KeyType = keyHT
-	KeySpace     KeyType = keySP
 	KeyEsc       KeyType = keyESC
 	KeyEscape    KeyType = keyESC
 
@@ -200,6 +197,19 @@ const (
 	KeyPgUp
 	KeyPgDown
 	KeyDelete
+	KeySpace
+	KeyCtrlUp
+	KeyCtrlDown
+	KeyCtrlRight
+	KeyCtrlLeft
+	KeyShiftUp
+	KeyShiftDown
+	KeyShiftRight
+	KeyShiftLeft
+	KeyCtrlShiftUp
+	KeyCtrlShiftDown
+	KeyCtrlShiftLeft
+	KeyCtrlShiftRight
 	KeyF1
 	KeyF2
 	KeyF3
@@ -222,9 +232,10 @@ const (
 	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",
@@ -256,48 +267,130 @@ 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",
-	KeyDelete:   "delete",
-	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",
+	// 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",
 }
 
 // Sequence mappings.
 var sequences = map[string]Key{
-	"\x1b[A": {Type: KeyUp},
-	"\x1b[B": {Type: KeyDown},
-	"\x1b[C": {Type: KeyRight},
-	"\x1b[D": {Type: KeyLeft},
+	// 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
@@ -366,28 +459,8 @@ var sequences = map[string]Key{
 
 // 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},
@@ -407,7 +480,7 @@ func readInputs(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 := parseX10MouseEvents(buf[:numBytes])
 	if err == nil {
@@ -418,35 +491,7 @@ func readInputs(input io.Reader) ([]Msg, error) {
 		return m, nil
 	}
 
-	// Is it a special sequence, like an arrow key?
-	if k, ok := sequences[string(buf[:numBytes])]; ok {
-		return []Msg{
-			KeyMsg(k),
-		}, nil
-	}
-
-	// Some of these need special handling
-	hex := fmt.Sprintf("%x", buf[:numBytes])
-	if k, ok := hexes[hex]; ok {
-		return []Msg{
-			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")
-		}
-		return []Msg{
-			KeyMsg(Key{Alt: true, Type: KeyRunes, Runes: []rune{c}}),
-		}, nil
-	}
-
+	var runeSets [][]rune
 	var runes []rune
 	b := buf[:numBytes]
 
@@ -458,30 +503,64 @@ func readInputs(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 []Msg{
-			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 []Msg{
-			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 []Msg{
-		KeyMsg(Key{Type: KeyRunes, Runes: runes}),
-	}, nil
+	return msgs, nil
 }
diff --git a/logging.go b/logging.go
index bca66ca..54a4350 100644
--- a/logging.go
+++ b/logging.go
@@ -12,12 +12,12 @@ import (
 //
 // Don't forget to close the file when you're done with it.
 //
-//   f, err := LogToFile("debug.log", "debug")
-//   if err != nil {
-//		fmt.Println("fatal:", err)
-//		os.Exit(1)
-//   }
-//   defer f.Close()
+//	  f, err := LogToFile("debug.log", "debug")
+//	  if err != nil {
+//			fmt.Println("fatal:", err)
+//			os.Exit(1)
+//	  }
+//	  defer f.Close()
 func LogToFile(path string, prefix string) (*os.File, error) {
 	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
 	if err != nil {
diff --git a/mouse.go b/mouse.go
index 5fb7427..5ff3a2f 100644
--- a/mouse.go
+++ b/mouse.go
@@ -63,7 +63,7 @@ var mouseEventTypes = map[MouseEventType]string{
 //
 // X10 mouse events look like:
 //
-//     ESC [M Cb Cx Cy
+//	ESC [M Cb Cx Cy
 //
 // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
 func parseX10MouseEvents(buf []byte) ([]MouseEvent, error) {
diff --git a/options.go b/options.go
index 09e5ecb..b60d23c 100644
--- a/options.go
+++ b/options.go
@@ -7,8 +7,7 @@ import "io"
 //
 // Example usage:
 //
-//     p := NewProgram(model, WithInput(someInput), WithOutput(someOutput))
-//
+//	p := NewProgram(model, WithInput(someInput), WithOutput(someOutput))
 type ProgramOption func(*Program)
 
 // WithOutput sets the output which, by default, is stdout. In most cases you
@@ -51,11 +50,11 @@ func WithoutCatchPanics() ProgramOption {
 //
 // Example:
 //
-//     p := tea.NewProgram(Model{}, tea.WithAltScreen())
-//     if err := p.Start(); err != nil {
-//         fmt.Println("Error running program:", err)
-//         os.Exit(1)
-//     }
+//	p := tea.NewProgram(Model{}, tea.WithAltScreen())
+//	if err := p.Start(); err != nil {
+//	    fmt.Println("Error running program:", err)
+//	    os.Exit(1)
+//	}
 //
 // To enter the altscreen once the program has already started running use the
 // EnterAltScreen command.
diff --git a/renderer.go b/renderer.go
index 5cb0163..aedbb66 100644
--- a/renderer.go
+++ b/renderer.go
@@ -15,7 +15,10 @@ type renderer interface {
 	// 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.
@@ -25,3 +28,6 @@ type renderer interface {
 	// 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 c7ca029..ef9ed9a 100644
--- a/signals_unix.go
+++ b/signals_unix.go
@@ -1,5 +1,5 @@
-//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix
+// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix
 
 package tea
 
diff --git a/standard_renderer.go b/standard_renderer.go
index 33d14a7..d2bc387 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -2,6 +2,7 @@ package tea
 
 import (
 	"bytes"
+	"fmt"
 	"io"
 	"strings"
 	"sync"
@@ -23,16 +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
-	useANSICompressor bool
-	once              sync.Once
+	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
@@ -49,10 +51,11 @@ type standardRenderer struct {
 // with os.Stdout as the first argument.
 func newRenderer(out io.Writer, mtx *sync.Mutex, useANSICompressor bool) renderer {
 	r := &standardRenderer{
-		out:               out,
-		mtx:               mtx,
-		framerate:         defaultFramerate,
-		useANSICompressor: useANSICompressor,
+		out:                out,
+		mtx:                mtx,
+		framerate:          defaultFramerate,
+		useANSICompressor:  useANSICompressor,
+		queuedMessageLines: []string{},
 	}
 	if r.useANSICompressor {
 		r.out = &compressor.Writer{Forward: out}
@@ -122,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 {
@@ -163,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)
@@ -192,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.
@@ -345,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
@@ -357,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:
@@ -368,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:
@@ -376,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()
+		}
 	}
 }
 
@@ -453,3 +478,38 @@ func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
 		}
 	}
 }
+
+type printLineMessage struct {
+	messageBody string
+}
+
+// Println prints above the Program. This output is unmanaged by the program and
+// will persist across renders by the Program.
+//
+// Unlike fmt.Println (but similar to log.Println) 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 83e96a1..28065a0 100644
--- a/tea.go
+++ b/tea.go
@@ -11,7 +11,6 @@ package tea
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -23,6 +22,7 @@ import (
 
 	"github.com/containerd/console"
 	isatty "github.com/mattn/go-isatty"
+	"github.com/muesli/cancelreader"
 	te "github.com/muesli/termenv"
 	"golang.org/x/term"
 )
@@ -82,14 +82,20 @@ type Program struct {
 	// treated as bits. These options can be set via various ProgramOptions.
 	startupOptions startupOptions
 
+	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
@@ -97,6 +103,8 @@ type Program struct {
 	// is on by default.
 	CatchPanics bool
 
+	ignoreSignals bool
+
 	killc chan bool
 
 	console console.Console
@@ -116,10 +124,9 @@ type Program struct {
 //
 // Example:
 //
-//     func (m model) Init() Cmd {
-//	       return tea.Batch(someCommand, someOtherCommand)
-//     }
-//
+//	    func (m model) Init() Cmd {
+//		       return tea.Batch(someCommand, someOtherCommand)
+//	    }
 func Batch(cmds ...Cmd) Cmd {
 	var validCmds []Cmd
 	for _, c := range cmds {
@@ -263,14 +270,11 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 
 // StartReturningModel initializes the program. Returns the final model.
 func (p *Program) StartReturningModel() (Model, error) {
-	var (
-		cmds = make(chan Cmd)
-		errs = make(chan error)
-	)
+	cmds := make(chan Cmd)
+	p.errs = make(chan error)
 
 	// Channels for managing goroutine lifecycles.
 	var (
-		readLoopDone   = make(chan struct{})
 		sigintLoopDone = make(chan struct{})
 		cmdLoopDone    = make(chan struct{})
 		resizeLoopDone = make(chan struct{})
@@ -279,7 +283,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 		waitForGoroutines = func(withReadLoop bool) {
 			if withReadLoop {
 				select {
-				case <-readLoopDone:
+				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
@@ -293,7 +297,8 @@ func (p *Program) StartReturningModel() (Model, error) {
 		}
 	)
 
-	ctx, cancelContext := context.WithCancel(context.Background())
+	var cancelContext context.CancelFunc
+	p.ctx, cancelContext = context.WithCancel(context.Background())
 	defer cancelContext()
 
 	switch {
@@ -344,10 +349,16 @@ func (p *Program) StartReturningModel() (Model, error) {
 			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
+				}
+			}
 		}
 	}()
 
@@ -390,7 +401,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 			defer close(initSignalDone)
 			select {
 			case cmds <- initCmd:
-			case <-ctx.Done():
+			case <-p.ctx.Done():
 			}
 		}()
 	} else {
@@ -404,57 +415,32 @@ func (p *Program) StartReturningModel() (Model, error) {
 	// Render the initial view.
 	p.renderer.write(model.View())
 
-	cancelReader, err := newCancelReader(p.input)
-	if err != nil {
-		return model, err
-	}
-
-	defer cancelReader.Close() // nolint:errcheck
-
 	// Subscribe to user input.
 	if p.input != nil {
-		go func() {
-			defer close(readLoopDone)
-
-			for {
-				if ctx.Err() != nil {
-					return
-				}
-
-				msgs, err := readInputs(cancelReader)
-				if err != nil {
-					if !errors.Is(err, io.EOF) && !errors.Is(err, errCanceled) {
-						errs <- err
-					}
-
-					return
-				}
-
-				for _, msg := range msgs {
-					p.msgs <- msg
-				}
-			}
-		}()
+		if err := p.initCancelReader(); err != nil {
+			return model, err
+		}
 	} else {
-		defer close(readLoopDone)
+		defer close(p.readLoopDone)
 	}
+	defer p.cancelReader.Close() // nolint:errcheck
 
 	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 <-ctx.Done():
+			case <-p.ctx.Done():
 			case p.msgs <- WindowSizeMsg{w, h}:
 			}
 		}()
 
 		// Listen for window resizes.
-		go listenForResize(ctx, f, p.msgs, errs, resizeLoopDone)
+		go listenForResize(p.ctx, f, p.msgs, p.errs, resizeLoopDone)
 	} else {
 		close(resizeLoopDone)
 	}
@@ -465,7 +451,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 
 		for {
 			select {
-			case <-ctx.Done():
+			case <-p.ctx.Done():
 
 				return
 			case cmd := <-cmds:
@@ -481,7 +467,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 				go func() {
 					select {
 					case p.msgs <- cmd():
-					case <-ctx.Done():
+					case <-p.ctx.Done():
 					}
 				}()
 			}
@@ -493,9 +479,9 @@ func (p *Program) StartReturningModel() (Model, error) {
 		select {
 		case <-p.killc:
 			return nil, nil
-		case err := <-errs:
+		case err := <-p.errs:
 			cancelContext()
-			waitForGoroutines(cancelReader.Cancel())
+			waitForGoroutines(p.cancelReader.Cancel())
 			p.shutdown(false)
 			return model, err
 
@@ -505,7 +491,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 			switch msg := msg.(type) {
 			case quitMsg:
 				cancelContext()
-				waitForGoroutines(cancelReader.Cancel())
+				waitForGoroutines(p.cancelReader.Cancel())
 				p.shutdown(false)
 				return model, nil
 
@@ -516,7 +502,9 @@ func (p *Program) StartReturningModel() (Model, error) {
 				continue
 
 			case WindowSizeMsg:
+				p.mtx.Lock()
 				p.renderer.repaint()
+				p.mtx.Unlock()
 
 			case enterAltScreenMsg:
 				p.EnterAltScreen()
@@ -536,6 +524,10 @@ func (p *Program) StartReturningModel() (Model, error) {
 
 			case hideCursorMsg:
 				hideCursor(p.output)
+
+			case execMsg:
+				// NB: this blocks.
+				p.exec(msg.cmd, msg.fn)
 			}
 
 			// Process internal messages for the renderer.
@@ -598,13 +590,13 @@ func (p *Program) shutdown(kill bool) {
 	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()
@@ -613,8 +605,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 {
@@ -624,7 +615,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()
@@ -633,7 +624,7 @@ func (p *Program) ExitAltScreen() {
 		return
 	}
 
-	fmt.Fprintf(p.output, te.CSI+te.ExitAltScreenSeq)
+	exitAltScreen(p.output)
 
 	p.altScreenActive = false
 	if p.renderer != nil {
@@ -644,7 +635,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()
@@ -654,7 +645,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()
@@ -665,7 +656,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()
@@ -675,9 +666,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 0aadf9e..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 {
@@ -29,3 +38,43 @@ func (p Program) restoreTerminal() error {
 
 	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 74fe8df..b6f5ffd 100644
--- a/tty_unix.go
+++ b/tty_unix.go
@@ -1,5 +1,5 @@
-//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix
+// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix
 
 package tea
 
diff --git a/tutorials/basics/README.md b/tutorials/basics/README.md
index 31bc382..3fcfa66 100644
--- a/tutorials/basics/README.md
+++ b/tutorials/basics/README.md
@@ -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 shopping 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,8 +52,8 @@ type model struct {
 
 ## Initialization
 
-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
+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
@@ -70,8 +70,8 @@ func initialModel() model {
 }
 ```
 
-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
+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
@@ -152,7 +152,7 @@ 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
-simplest. We look at the  model in it's current state and use it to return
+simplest. We look at the model in its 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
@@ -230,11 +230,12 @@ 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)
+* [Slack](https://charm.sh/slack)
 
 ***
 
 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/tutorials/commands/README.md b/tutorials/commands/README.md
index cfeb639..a1275f1 100644
--- a/tutorials/commands/README.md
+++ b/tutorials/commands/README.md
@@ -236,11 +236,12 @@ 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)
+* [Slack](https://charm.sh/slack)
 
 ***
 
 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/tutorials/go.mod b/tutorials/go.mod
index 27661c2..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.19.3
+require github.com/charmbracelet/bubbletea v0.21.0
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/tutorials/go.sum b/tutorials/go.sum
index 80de060..7bb6610 100644
--- a/tutorials/go.sum
+++ b/tutorials/go.sum
@@ -2,13 +2,16 @@ github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARu
 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.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 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.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/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=
@@ -19,7 +22,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/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-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158/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-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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/key_test.go b/key_test.go
index 931fa84..b74f06b 100644
--- a/key_test.go
+++ b/key_test.go
@@ -8,10 +8,10 @@ import (
 func TestKeyString(t *testing.T) {
 	t.Run("alt+space", func(t *testing.T) {
 		if got := KeyMsg(Key{
-			Type: keySP,
+			Type: KeySpace,
 			Alt:  true,
-		}).String(); got != "alt+space" {
-			t.Fatalf(`expected a "alt+space", got %q`, got)
+		}).String(); got != "alt+ " {
+			t.Fatalf(`expected a "alt+ ", got %q`, got)
 		}
 	})
 
@@ -35,8 +35,8 @@ func TestKeyString(t *testing.T) {
 
 func TestKeyTypeString(t *testing.T) {
 	t.Run("space", func(t *testing.T) {
-		if got := keySP.String(); got != "space" {
-			t.Fatalf(`expected a "space", got %q`, got)
+		if got := KeySpace.String(); got != " " {
+			t.Fatalf(`expected a " ", got %q`, got)
 		}
 	})
 
@@ -48,29 +48,119 @@ func TestKeyTypeString(t *testing.T) {
 }
 
 func TestReadInput(t *testing.T) {
-	for out, in := range map[string][]byte{
-		"a":         {'a'},
-		"ctrl+a":    {byte(keySOH)},
-		"alt+a":     {0x1b, 'a'},
-		"abcd":      {'a', 'b', 'c', 'd'},
-		"up":        []byte("\x1b[A"),
-		"wheel up":  {'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
-		"shift+tab": {'\x1b', '[', 'Z'},
+	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(in))
+			msgs, err := readInputs(bytes.NewReader(td.in))
 			if err != nil {
 				t.Fatalf("unexpected error: %v", err)
 			}
-			if len(msgs) == 0 {
-				t.Fatalf("unexpected empty message list")
+			if len(msgs) != len(td.out) {
+				t.Fatalf("unexpected message list length")
 			}
 
-			if m, ok := msgs[0].(KeyMsg); ok && m.String() != out {
-				t.Fatalf(`expected a keymsg %q, got %q`, out, m)
+			if len(msgs) == 1 {
+				if m, ok := msgs[0].(KeyMsg); ok && m.String() != out {
+					t.Fatalf(`expected a keymsg %q, got %q`, out, m)
+				}
 			}
-			if m, ok := msgs[0].(MouseMsg); ok && mouseEventTypes[m.Type] != out {
-				t.Fatalf(`expected a mousemsg %q, got %q`, out, mouseEventTypes[m.Type])
+
+			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/options_test.go b/options_test.go
index 4b75994..1b77977 100644
--- a/options_test.go
+++ b/options_test.go
@@ -18,10 +18,10 @@ func TestOptions(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.output)
+			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.output)
+			t.Errorf("expected startup options to have custom input set, got %v", p.input)
 		}
 	})