Repository: charmbracelet/bubbletea
Pull request #337 (2022-06-07 08:55:44)

Add sku to Bubble Tea in the Wild


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/.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 9cc4e61..4d632ad 100644
--- a/README.md
+++ b/README.md
@@ -21,241 +21,32 @@ 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
-
-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
+### Debugging with Delve
 
 Since Bubble Tea apps assume control of stdin and stdout, you’ll need to run
 delve in headless mode and then connect to it:
@@ -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,6 +140,7 @@ 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
 
@@ -330,14 +152,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/commands.go b/commands.go
index 99007b2..9111638 100644
--- a/commands.go
+++ b/commands.go
@@ -19,38 +19,38 @@ 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
-//   }
+//	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 {
@@ -69,38 +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
-//   }
-//
+//	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)
@@ -112,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/go.mod b/examples/go.mod
index 7851f35..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.11.0
-	github.com/charmbracelet/bubbletea v0.21.0
+	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 19af625..4011628 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -2,10 +2,12 @@ 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.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q=
-github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+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.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
@@ -28,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=
@@ -39,15 +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.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
-github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+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=
@@ -74,8 +78,9 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/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
index d1d9d69..587d91a 100644
--- a/exec.go
+++ b/exec.go
@@ -34,17 +34,17 @@ func Exec(c ExecCommand, fn ExecCallback) Cmd {
 // a message containing the error which may have occurred when running the
 // ExecCommand.
 //
-//     type VimFinishedMsg struct { err error }
+//	type VimFinishedMsg struct { err error }
 //
-//     c := exec.Command("vim", "file.txt")
+//	c := exec.Command("vim", "file.txt")
 //
-//     cmd := ExecProcess(c, func(err error) Msg {
-//         return VimFinishedMsg{err: error}
-//     })
+//	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)
+//	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 {
diff --git a/go.mod b/go.mod
index 805e076..8da123a 100644
--- a/go.mod
+++ b/go.mod
@@ -4,11 +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.0
+	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 fadb408..7bb6610 100644
--- a/go.sum
+++ b/go.sum
@@ -2,15 +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.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
-github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+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=
@@ -22,7 +23,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/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 e2415a9..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) {
@@ -492,35 +491,7 @@ func readInputs(input io.Reader) ([]Msg, error) {
 		return m, nil
 	}
 
-	// Is it a 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? If so, the buffer will be prefixed with an
-	// escape.
-	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]
 
@@ -532,38 +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
+		}
 
-	// If it's a space, override the type with KeySpace (but still include the
-	// rune).
-	if runes[0] == ' ' {
-		return []Msg{
-			KeyMsg(Key{Type: KeySpace, Runes: runes}),
-		}, nil
+		// 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/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 4e75403..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.
@@ -383,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()
+		}
 	}
 }
 
@@ -460,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 73be9bd..28065a0 100644
--- a/tea.go
+++ b/tea.go
@@ -124,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 {
@@ -597,7 +596,7 @@ func (p *Program) shutdown(kill bool) {
 // 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()
@@ -616,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()
@@ -636,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()
@@ -646,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()
@@ -657,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()
@@ -667,7 +666,7 @@ 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()
@@ -709,3 +708,27 @@ func (p *Program) RestoreTerminal() error {
 
 	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_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.sum b/tutorials/go.sum
index fadb408..7bb6610 100644
--- a/tutorials/go.sum
+++ b/tutorials/go.sum
@@ -2,15 +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.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
-github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+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=
@@ -22,7 +23,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/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 511ae75..b74f06b 100644
--- a/key_test.go
+++ b/key_test.go
@@ -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])
+				}
 			}
 		})
 	}