Repository: charmbracelet/bubbletea
Pull request #561 (2022-10-20 01:03:21)

feat(ci): auto go mod tidy examples
this should prevent having to manually run `go mod tidy` in the `examples` folder.


Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c622d76..9065bc6 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,7 +4,6 @@ updates:
     directory: "/"
     schedule:
       interval: "daily"
-      time: "08:00"
     labels:
       - "dependencies"
     commit-message:
@@ -14,7 +13,6 @@ updates:
     directory: "/examples"
     schedule:
       interval: "daily"
-      time: "08:00"
     labels:
       - "dependencies"
     commit-message:
@@ -24,7 +22,6 @@ updates:
     directory: "/tutorials"
     schedule:
       interval: "daily"
-      time: "08:00"
     labels:
       - "dependencies"
     commit-message:
@@ -34,7 +31,6 @@ updates:
     directory: "/"
     schedule:
       interval: "daily"
-      time: "08:00"
     labels:
       - "dependencies"
     commit-message:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5d086b2..b662a60 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,7 +5,7 @@ jobs:
   build:
     strategy:
       matrix:
-        go-version: [~1.16, ^1]
+        go-version: [~1.17, ^1]
         os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
     env:
diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml
new file mode 100644
index 0000000..eed1bbc
--- /dev/null
+++ b/.github/workflows/examples.yml
@@ -0,0 +1,38 @@
+name: examples
+
+on:
+  push:
+    branches:
+      - 'master'
+    paths:
+      - '.github/workflows/examples.yml'
+      - './examples/go.mod'
+      - './examples/go.sum'
+      - './tutorials/go.mod'
+      - './tutorials/go.sum'
+      - './go.mod'
+      - './go.sum'
+  workflow_dispatch: {}
+
+jobs:
+  tidy:
+    permissions:
+      contents: write
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-go@v3
+        with:
+          go-version: '^1'
+          cache: true
+      - shell: bash
+        run: |
+          (cd ./examples && go mod tidy)
+          (cd ./tutorials && go mod tidy)
+      - uses: stefanzweifel/git-auto-commit-action@v4
+        with:
+          commit_message: "chore: go mod tidy tutorials and examples"
+          branch: master
+          commit_user_name: actions-user
+          commit_user_email: actions@github.com
+
diff --git a/README.md b/README.md
index 490e252..0e1bc48 100644
--- a/README.md
+++ b/README.md
@@ -11,11 +11,9 @@ The fun, functional and stateful way to build terminal apps. A Go framework
 based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and
 complex terminal applications, either inline, full-window, or a mix of both.
 
-<picture>
-  <source media="(max-width: 800px)" srcset="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0">
-  <source media="(min-width: 800px)" width="800" srcset="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0">
-  <img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0" alt="Bubble Tea Example">
-</picture>
+<p>
+    <img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif" width="100%" alt="Bubble Tea Example">
+</p>
 
 Bubble Tea is in use in production and includes a number of features and
 performance optimizations we’ve added along the way. Among those is a standard
@@ -36,7 +34,7 @@ Be sure to check out [Bubbles][bubbles], a library of common UI components for B
     <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
 
@@ -318,59 +316,67 @@ your program in another window.
 
 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
+* [AT CLI](https://github.com/daskycodes/at_cli): execute AT Commands via serial port connections
 * [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform
+* [brows](https://github.com/rubysolo/brows): a GitHub release browser
 * [Canard](https://github.com/mrusme/canard): an RSS client
 * [charm](https://github.com/charmbracelet/charm): the official Charm user account manager
-* [chezmoi](https://github.com/twpayne/chezmoi): manage your dotfiles across multiple machines, securely
-* [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in your terminal
-* [clidle](https://github.com/ajeetdsouza/clidle): a Wordle clone for your terminal
+* [chezmoi](https://github.com/twpayne/chezmoi): securely manage your dotfiles across multiple machines
+* [chtop](https://github.com/chhetripradeep/chtop): monitor your ClickHouse node without leaving terminal
+* [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal
+* [clidle](https://github.com/ajeetdsouza/clidle): a Wordle clone
+* [cLive](https://github.com/koki-develop/clive): automate terminal operations and view them live in a browser
 * [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
+* [dns53](https://github.com/purpleclay/dns53): dynamic DNS with Amazon Route53: expose your EC2 quickly, securely and privately
+* [enola](https://github.com/sherlock-project/enola): hunt down social media accounts by username across social networks
 * [flapioca](https://github.com/kbrgl/flapioca): Flappy Bird on the CLI!
 * [fm](https://github.com/knipferrc/fm): a terminal-based file manager
-* [fork-cleaner](https://github.com/caarlos0/fork-cleaner): cleans up old and inactive forks in your GitHub account
-* [fztea](https://github.com/jon4hz/fztea): connect to your Flipper's UI over serial or make it accessible via SSH
-* [gambit](https://github.com/maaslalani/gambit): play chess in the terminal
+* [fork-cleaner](https://github.com/caarlos0/fork-cleaner): clean up old and inactive forks in your GitHub account
+* [fztea](https://github.com/jon4hz/fztea): a Flipper Zero TUI
+* [gambit](https://github.com/maaslalani/gambit): chess in the terminal
 * [gembro](https://git.sr.ht/~rafael/gembro): a mouse-driven Gemini browser
-* [gh-b](https://github.com/joaom00/gh-b): GitHub CLI extension to easily manage your branches
-* [gh-dash](https://www.github.com/dlvhdr/gh-dash): GitHub CLI extension to display a dashboard of PRs and issues
+* [gh-b](https://github.com/joaom00/gh-b): a GitHub CLI extension for managing branches
+* [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for 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
+* [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
 * [got](https://github.com/fedeztk/got): a simple translator and text-to-speech app build on top of simplytranslate's APIs
 * [httpit](https://github.com/gonetx/httpit): a rapid http(s) benchmark tool
-* [IDNT](https://github.com/r-darwish/idnt): batch software uninstaller
+* [IDNT](https://github.com/r-darwish/idnt): a batch software uninstaller
 * [kboard](https://github.com/CamiloGarciaLaRotta/kboard): a typing game
-* [mandelbrot-cli](https://github.com/MicheleFiladelfia/mandelbrot-cli): Multiplatform terminal mandelbrot set explorer
+* [mandelbrot-cli](https://github.com/MicheleFiladelfia/mandelbrot-cli): a multiplatform terminal mandelbrot set explorer
 * [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client
 * [mergestat](https://github.com/mergestat/mergestat): run SQL queries on git repositories
-* [Noted](https://github.com/torbratsberg/noted): Note viewer and manager
-* [pathos](https://github.com/chip/pathos): pathos - CLI for editing a PATH env variable
-* [portal](https://github.com/ZinoKader/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
+* [Neon Modem Overdrive](https://github.com/mrusme/neonmodem): a BBS-style TUI client for Discourse, Lemmy, Lobste.rs and Hacker News
+* [Noted](https://github.com/torbratsberg/noted): a note viewer and manager
+* [pathos](https://github.com/chip/pathos): a PATH env variable editor
+* [portal](https://github.com/ZinoKader/portal): secure transfers between computers
+* [redis-viewer](https://github.com/SaltFishPr/redis-viewer): a Redis databases browser
+* [sku](https://github.com/fedeztk/sku): Sudoku on the CLI
 * [Slides](https://github.com/maaslalani/slides): a markdown-based presentation tool
+* [SlurmCommander](https://github.com/CLIP-HPC/SlurmCommander): a Slurm workload manager TUI
 * [Soft Serve](https://github.com/charmbracelet/soft-serve): a command-line-first Git server that runs a TUI over SSH
+* [solitaire-tui](https://github.com/brianstrauch/solitaire-tui): Klondike Solitaire for the terminal
 * [StormForge Optimize Controller](https://github.com/thestormforge/optimize-controller): a tool for experimenting with application configurations in Kubernetes
-* [STTG](https://github.com/wille1101/sttg): teletext client for SVT, Sweden’s national public television station
-* [sttr](https://github.com/abhimanyu003/sttr): run various text transformations
+* [STTG](https://github.com/wille1101/sttg): a teletext client for SVT, Sweden’s national public television station
+* [sttr](https://github.com/abhimanyu003/sttr): a general-purpose text transformer
 * [tasktimer](https://github.com/caarlos0/tasktimer): a dead-simple task timer
 * [termdbms](https://github.com/mathaou/termdbms): a keyboard and mouse driven database browser
-* [ticker](https://github.com/achannarasappa/ticker): a terminal stock watcher and stock position tracker
-* [tran](https://github.com/abdfnx/tran): securely transfer stuff between computers (based on [portal][portal])
+* [ticker](https://github.com/achannarasappa/ticker): a terminal stock viewer and stock position tracker
+* [tran](https://github.com/abdfnx/tran): securely transfer stuff between computers (based on [portal](https://github.com/ZinoKader/portal))
 * [Typer](https://github.com/maaslalani/typer): a typing test
 * [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
-* [wander](https://github.com/robinovitch61/wander): HashiCorp Nomad terminal client
+* [wander](https://github.com/robinovitch61/wander): a HashiCorp Nomad terminal client
 * [wishlist](https://github.com/charmbracelet/wishlist): an SSH directory
 
 ## Feedback
 
-We'd love to hear your thoughts on this tutorial. Feel free to drop us a note!
+We'd love to hear your thoughts on this project. Feel free to drop us a note!
 
 * [Twitter](https://twitter.com/charmcli)
-* [The Fediverse](https://mastodon.technology/@charm)
+* [The Fediverse](https://mastodon.social/@charmcli)
+* [Discord](https://charm.sh/chat)
 
 ## Acknowledgments
 
@@ -387,10 +393,10 @@ of days past.
 
 [MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE)
 
-* * *
+***
 
 Part of [Charm](https://charm.sh).
 
 <a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
 
-Charm 热爱开源 • Charm loves open source
+Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
diff --git a/commands.go b/commands.go
index 08f1a2e..7c30a12 100644
--- a/commands.go
+++ b/commands.go
@@ -24,13 +24,13 @@ func Batch(cmds ...Cmd) Cmd {
 		return nil
 	}
 	return func() Msg {
-		return batchMsg(validCmds)
+		return BatchMsg(validCmds)
 	}
 }
 
-// batchMsg is the internal message used to perform a bunch of commands. You
-// can send a batchMsg with Batch.
-type batchMsg []Cmd
+// BatchMsg is a message used to perform a bunch of commands concurrently with
+// no ordering guarantees. You can send a BatchMsg with Batch.
+type BatchMsg []Cmd
 
 // Sequence runs the given commands one at a time, in order. Contrast this with
 // Batch, which runs commands concurrently.
@@ -40,7 +40,7 @@ func Sequence(cmds ...Cmd) Cmd {
 	}
 }
 
-// sequenceMsg is used interally to run the given commands in order.
+// sequenceMsg is used internally to run the given commands in order.
 type sequenceMsg []Cmd
 
 // Every is a command that ticks in sync with the system clock. So, if you
diff --git a/examples/cellbuffer/main.go b/examples/cellbuffer/main.go
new file mode 100644
index 0000000..00de39c
--- /dev/null
+++ b/examples/cellbuffer/main.go
@@ -0,0 +1,199 @@
+package main
+
+// A simple example demonstrating how to draw and animate on a cellular grid.
+// Note that the cellbuffer implementation in this example does not support
+// double-width runes.
+
+import (
+	"fmt"
+	"os"
+	"strings"
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/harmonica"
+)
+
+const (
+	fps       = 60
+	frequency = 7.5
+	damping   = 0.15
+)
+
+func drawEllipse(cb *cellbuffer, xc, yc, rx, ry float64) {
+	const c = "*"
+	var (
+		dx, dy, d1, d2 float64
+		x              float64 = 0
+		y                      = ry
+	)
+
+	d1 = ry*ry - rx*rx*ry + 0.25*rx*rx
+	dx = 2 * ry * ry * x
+	dy = 2 * rx * rx * y
+
+	for dx < dy {
+		cb.set(c, int(x+xc), int(y+yc))
+		cb.set(c, int(-x+xc), int(y+yc))
+		cb.set(c, int(x+xc), int(-y+yc))
+		cb.set(c, int(-x+xc), int(-y+yc))
+		if d1 < 0 {
+			x++
+			dx = dx + (2 * ry * ry)
+			d1 = d1 + dx + (ry * ry)
+		} else {
+			x++
+			y--
+			dx = dx + (2 * ry * ry)
+			dy = dy - (2 * rx * rx)
+			d1 = d1 + dx - dy + (ry * ry)
+		}
+	}
+
+	d2 = ((ry * ry) * ((x + 0.5) * (x + 0.5))) + ((rx * rx) * ((y - 1) * (y - 1))) - (rx * rx * ry * ry)
+
+	for y >= 0 {
+		cb.set(c, int(x+xc), int(y+yc))
+		cb.set(c, int(-x+xc), int(y+yc))
+		cb.set(c, int(x+xc), int(-y+yc))
+		cb.set(c, int(-x+xc), int(-y+yc))
+		if d2 > 0 {
+			y--
+			dy = dy - (2 * rx * rx)
+			d2 = d2 + (rx * rx) - dy
+		} else {
+			y--
+			x++
+			dx = dx + (2 * ry * ry)
+			dy = dy - (2 * rx * rx)
+			d2 = d2 + dx - dy + (rx * rx)
+		}
+	}
+}
+
+type cellbuffer struct {
+	cells  []string
+	stride int
+}
+
+func (c *cellbuffer) init(w, h int) {
+	if w == 0 {
+		return
+	}
+	c.stride = w
+	c.cells = make([]string, w*h)
+	c.wipe()
+}
+
+func (c cellbuffer) set(v string, x, y int) {
+	i := y*c.stride + x
+	if i > len(c.cells)-1 || x < 0 || y < 0 || x >= c.width() || y >= c.height() {
+		return
+	}
+	c.cells[i] = v
+}
+
+func (c *cellbuffer) clear(x, y int) {
+	c.set(" ", x, y)
+}
+
+func (c *cellbuffer) wipe() {
+	for i := range c.cells {
+		c.cells[i] = " "
+	}
+}
+
+func (c cellbuffer) width() int {
+	return c.stride
+}
+
+func (c cellbuffer) height() int {
+	h := len(c.cells) / c.stride
+	if len(c.cells)%c.stride != 0 {
+		h++
+	}
+	return h
+}
+
+func (c cellbuffer) ready() bool {
+	return len(c.cells) > 0
+}
+
+func (c cellbuffer) String() string {
+	var b strings.Builder
+	for i := 0; i < len(c.cells); i++ {
+		if i > 0 && i%c.stride == 0 && i < len(c.cells)-1 {
+			b.WriteRune('\n')
+		}
+		b.WriteString(c.cells[i])
+	}
+	return b.String()
+}
+
+type frameMsg struct{}
+
+func animate() tea.Cmd {
+	return tea.Tick(time.Second/fps, func(_ time.Time) tea.Msg {
+		return frameMsg{}
+	})
+}
+
+type model struct {
+	cells                cellbuffer
+	spring               harmonica.Spring
+	targetX, targetY     float64
+	x, y                 float64
+	xVelocity, yVelocity float64
+}
+
+func (m model) Init() tea.Cmd {
+	return animate()
+}
+
+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:
+		if !m.cells.ready() {
+			m.targetX, m.targetY = float64(msg.Width)/2, float64(msg.Height)/2
+		}
+		m.cells.init(msg.Width, msg.Height)
+		return m, nil
+	case tea.MouseMsg:
+		if !m.cells.ready() {
+			return m, nil
+		}
+		m.targetX, m.targetY = float64(msg.X), float64(msg.Y)
+		return m, nil
+
+	case frameMsg:
+		if !m.cells.ready() {
+			return m, nil
+		}
+
+		m.cells.wipe()
+		m.x, m.xVelocity = m.spring.Update(m.x, m.xVelocity, m.targetX)
+		m.y, m.yVelocity = m.spring.Update(m.y, m.yVelocity, m.targetY)
+		drawEllipse(&m.cells, m.x, m.y, 16, 8)
+		return m, animate()
+	default:
+		return m, nil
+	}
+}
+
+func (m model) View() string {
+	return m.cells.String()
+}
+
+func main() {
+	m := model{
+		spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping),
+	}
+
+	p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
+	if _, err := p.Run(); err != nil {
+		fmt.Println("Uh oh:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/credit-card-form/main.go b/examples/credit-card-form/main.go
index 5ac6854..ba5b2b3 100644
--- a/examples/credit-card-form/main.go
+++ b/examples/credit-card-form/main.go
@@ -71,7 +71,7 @@ func ccnValidator(s string) error {
 
 func expValidator(s string) error {
 	// The 3 character should be a slash (/)
-	// The rest thould be numbers
+	// The rest should be numbers
 	e := strings.ReplaceAll(s, "/", "")
 	_, err := strconv.ParseInt(e, 10, 64)
 	if err != nil {
diff --git a/examples/go.mod b/examples/go.mod
index 042b22f..47bf1ab 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -1,17 +1,43 @@
 module examples
 
-go 1.16
+go 1.17
 
 require (
-	github.com/charmbracelet/bubbles v0.13.1-0.20220815142520-649f78e1fd8b
-	github.com/charmbracelet/bubbletea v0.22.0
-	github.com/charmbracelet/glamour v0.5.0
+	github.com/charmbracelet/bubbles v0.15.0
+	github.com/charmbracelet/bubbletea v0.23.2
+	github.com/charmbracelet/glamour v0.6.0
+	github.com/charmbracelet/harmonica v0.2.0
 	github.com/charmbracelet/lipgloss v0.6.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.16
+	github.com/mattn/go-isatty v0.0.17
 	github.com/muesli/reflow v0.3.0
-	github.com/muesli/termenv v0.13.0
+	github.com/muesli/termenv v0.14.0
+)
+
+require (
+	github.com/alecthomas/chroma v0.10.0 // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
+	github.com/aymerick/douceur v0.2.0 // indirect
+	github.com/containerd/console v1.0.3 // indirect
+	github.com/dlclark/regexp2 v1.4.0 // indirect
+	github.com/gorilla/css v1.0.0 // indirect
+	github.com/mattn/go-localereader v0.0.1 // indirect
+	github.com/mattn/go-runewidth v0.0.14 // indirect
+	github.com/microcosm-cc/bluemonday v1.0.21 // indirect
+	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
+	github.com/olekukonko/tablewriter v0.0.5 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
+	github.com/sahilm/fuzzy v0.1.0 // indirect
+	github.com/yuin/goldmark v1.5.2 // indirect
+	github.com/yuin/goldmark-emoji v1.0.1 // indirect
+	golang.org/x/net v0.7.0 // indirect
+	golang.org/x/sync v0.1.0 // indirect
+	golang.org/x/sys v0.5.0 // indirect
+	golang.org/x/term v0.5.0 // indirect
+	golang.org/x/text v0.7.0 // indirect
 )
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/examples/go.sum b/examples/go.sum
index 65731b4..caa14e9 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -2,17 +2,17 @@ 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/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E=
+github.com/aymanbagabas/go-osc52 v1.2.1/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.13.1-0.20220815142520-649f78e1fd8b h1:ppafRlD8VXOEqnUPkMvO7et9rpSsKVjUX+K3QG7tAB0=
-github.com/charmbracelet/bubbles v0.13.1-0.20220815142520-649f78e1fd8b/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/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI=
+github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74=
+github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
+github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
 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/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
 github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY=
 github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
@@ -30,10 +30,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 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-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
@@ -42,8 +42,8 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
-github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
+github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
+github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
 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=
@@ -51,11 +51,10 @@ github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIf
 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/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
-github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
 github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
+github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0=
+github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8=
 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=
@@ -69,28 +68,50 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
-github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
+github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
 github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
-golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
-golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/help/main.go b/examples/help/main.go
index caedc9e..fb382ce 100644
--- a/examples/help/main.go
+++ b/examples/help/main.go
@@ -87,7 +87,7 @@ func (m model) Init() tea.Cmd {
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		// If we set a width on the help menu it can it can gracefully truncate
+		// If we set a width on the help menu it can gracefully truncate
 		// its view as needed.
 		m.help.Width = msg.Width
 
diff --git a/examples/pager/main.go b/examples/pager/main.go
index a98a9ec..f602ad7 100644
--- a/examples/pager/main.go
+++ b/examples/pager/main.go
@@ -5,7 +5,6 @@ package main
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
 	"strings"
 
@@ -129,7 +128,7 @@ func max(a, b int) int {
 
 func main() {
 	// Load some text for our viewport
-	content, err := ioutil.ReadFile("artichoke.md")
+	content, err := os.ReadFile("artichoke.md")
 	if err != nil {
 		fmt.Println("could not load file:", err)
 		os.Exit(1)
diff --git a/examples/pipe/main.go b/examples/pipe/main.go
index b60aa7e..1fce2fc 100644
--- a/examples/pipe/main.go
+++ b/examples/pipe/main.go
@@ -1,7 +1,7 @@
 package main
 
 // An example illustrating how to pipe in data to a Bubble Tea application.
-// Moreso, this serves as proof that Bubble Tea will automatically listen for
+// More so, this serves as proof that Bubble Tea will automatically listen for
 // keystrokes when input is not a TTY, such as when data is piped or redirected
 // in.
 
diff --git a/examples/sequence/main.go b/examples/sequence/main.go
index 200b738..ea46dcc 100644
--- a/examples/sequence/main.go
+++ b/examples/sequence/main.go
@@ -13,9 +13,12 @@ type model struct{}
 
 func (m model) Init() tea.Cmd {
 	return tea.Sequence(
-		tea.Println("A"),
-		tea.Println("B"),
-		tea.Println("C"),
+		tea.Batch(
+			tea.Println("A"),
+			tea.Println("B"),
+			tea.Println("C"),
+		),
+		tea.Println("Z"),
 		tea.Quit,
 	)
 }
diff --git a/examples/simple/main.go b/examples/simple/main.go
index 478f95a..f1201fd 100644
--- a/examples/simple/main.go
+++ b/examples/simple/main.go
@@ -56,7 +56,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-// Views return a string based on data in the model. That string which will be
+// View returns a string based on data in the model. That string which will be
 // rendered to the terminal.
 func (m model) View() string {
 	return fmt.Sprintf("Hi. This program will exit in %d seconds. To quit sooner press any key.\n", m)
diff --git a/examples/textarea/main.go b/examples/textarea/main.go
index 109059c..68d1097 100644
--- a/examples/textarea/main.go
+++ b/examples/textarea/main.go
@@ -1,6 +1,6 @@
 package main
 
-// A simple program demonstrating the text input component from the Bubbles
+// A simple program demonstrating the textarea component from the Bubbles
 // component library.
 
 import (
diff --git a/examples/tui-daemon-combo/main.go b/examples/tui-daemon-combo/main.go
index cbe7799..962da81 100644
--- a/examples/tui-daemon-combo/main.go
+++ b/examples/tui-daemon-combo/main.go
@@ -3,7 +3,7 @@ package main
 import (
 	"flag"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"log"
 	"math/rand"
 	"os"
@@ -41,7 +41,7 @@ func main() {
 		opts = []tea.ProgramOption{tea.WithoutRenderer()}
 	} else {
 		// If we're in TUI mode, discard log output
-		log.SetOutput(ioutil.Discard)
+		log.SetOutput(io.Discard)
 	}
 
 	p := tea.NewProgram(newModel(), opts...)
@@ -123,7 +123,7 @@ func (m model) View() string {
 	return indent.String(s, 1)
 }
 
-// processFinishedMsg is send when a pretend process completes.
+// processFinishedMsg is sent when a pretend process completes.
 type processFinishedMsg time.Duration
 
 // pretendProcess simulates a long-running process.
diff --git a/exec.go b/exec.go
index 7a14d2a..fb6d91e 100644
--- a/exec.go
+++ b/exec.go
@@ -109,7 +109,7 @@ func (p *Program) exec(c ExecCommand, fn ExecCallback) {
 	}
 
 	c.SetStdin(p.input)
-	c.SetStdout(p.output)
+	c.SetStdout(p.output.TTY())
 	c.SetStderr(os.Stderr)
 
 	// Execute system command.
diff --git a/go.mod b/go.mod
index d3a0c97..0111e65 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,24 @@
 module github.com/charmbracelet/bubbletea
 
-go 1.16
+go 1.17
 
 require (
 	github.com/containerd/console v1.0.3
-	github.com/mattn/go-isatty v0.0.16
+	github.com/mattn/go-isatty v0.0.17
 	github.com/mattn/go-localereader v0.0.1
 	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.13.0
+	github.com/muesli/termenv v0.14.0
+	golang.org/x/sync v0.1.0
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
-	golang.org/x/text v0.3.7 // indirect
+)
+
+require (
+	github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-runewidth v0.0.14 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
+	golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
+	golang.org/x/text v0.3.8 // indirect
 )
diff --git a/go.sum b/go.sum
index 76e0059..e915ca8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,11 @@
-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/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E=
+github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -18,18 +18,40 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
 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.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
-github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
+github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0=
+github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 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-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/mouse.go b/mouse.go
index 5ff3a2f..f918d20 100644
--- a/mouse.go
+++ b/mouse.go
@@ -5,9 +5,9 @@ import (
 	"errors"
 )
 
-// MouseMsg contains information about a mouse event and are sent to a programs
+// MouseMsg contains information about a mouse event and is sent to a program's
 // update function when mouse activity occurs. Note that the mouse must first
-// be enabled via in order the mouse events to be received.
+// be enabled in order for the mouse events to be received.
 type MouseMsg MouseEvent
 
 // MouseEvent represents a mouse event, which could be a click, a scroll wheel
diff --git a/options.go b/options.go
index 59d0685..9945924 100644
--- a/options.go
+++ b/options.go
@@ -1,6 +1,7 @@
 package tea
 
 import (
+	"context"
 	"io"
 
 	"github.com/muesli/termenv"
@@ -14,6 +15,15 @@ import (
 //	p := NewProgram(model, WithInput(someInput), WithOutput(someOutput))
 type ProgramOption func(*Program)
 
+// WithContext lets you specify a context in which to run the Program. This is
+// useful if you want to cancel the execution from outside. When a Program gets
+// cancelled it will exit with an error ErrProgramKilled.
+func WithContext(ctx context.Context) ProgramOption {
+	return func(p *Program) {
+		p.ctx = ctx
+	}
+}
+
 // WithOutput sets the output which, by default, is stdout. In most cases you
 // won't need to use this.
 func WithOutput(output io.Writer) ProgramOption {
@@ -31,7 +41,7 @@ func WithInput(input io.Reader) ProgramOption {
 	}
 }
 
-// WithInputTTY open a new TTY for input (or console input device on Windows).
+// WithInputTTY opens a new TTY for input (or console input device on Windows).
 func WithInputTTY() ProgramOption {
 	return func(p *Program) {
 		p.startupOptions |= withInputTTY
@@ -134,7 +144,7 @@ func WithoutRenderer() ProgramOption {
 // WithANSICompressor removes redundant ANSI sequences to produce potentially
 // smaller output, at the cost of some processing overhead.
 //
-// This feature is provisional, and may be changed removed in a future version
+// This feature is provisional, and may be changed or removed in a future version
 // of this package.
 func WithANSICompressor() ProgramOption {
 	return func(p *Program) {
diff --git a/signals_unix.go b/signals_unix.go
index ef9ed9a..826f58b 100644
--- a/signals_unix.go
+++ b/signals_unix.go
@@ -4,18 +4,15 @@
 package tea
 
 import (
-	"context"
 	"os"
 	"os/signal"
 	"syscall"
-
-	"golang.org/x/term"
 )
 
 // listenForResize sends messages (or errors) when the terminal resizes.
 // Argument output should be the file descriptor for the terminal; usually
 // os.Stdout.
-func listenForResize(ctx context.Context, output *os.File, msgs chan Msg, errs chan error, done chan struct{}) {
+func (p *Program) listenForResize(done chan struct{}) {
 	sig := make(chan os.Signal, 1)
 	signal.Notify(sig, syscall.SIGWINCH)
 
@@ -26,20 +23,11 @@ func listenForResize(ctx context.Context, output *os.File, msgs chan Msg, errs c
 
 	for {
 		select {
-		case <-ctx.Done():
+		case <-p.ctx.Done():
 			return
 		case <-sig:
 		}
 
-		w, h, err := term.GetSize(int(output.Fd()))
-		if err != nil {
-			errs <- err
-		}
-
-		select {
-		case <-ctx.Done():
-			return
-		case msgs <- WindowSizeMsg{w, h}:
-		}
+		p.checkResize()
 	}
 }
diff --git a/signals_windows.go b/signals_windows.go
index c60ca57..2fc6f8a 100644
--- a/signals_windows.go
+++ b/signals_windows.go
@@ -3,15 +3,8 @@
 
 package tea
 
-import (
-	"context"
-	"os"
-)
-
 // listenForResize is not available on windows because windows does not
 // implement syscall.SIGWINCH.
-func listenForResize(ctx context.Context, output *os.File, msgs chan Msg,
-	errs chan error, done chan struct{},
-) {
+func (p *Program) listenForResize(done chan struct{}) {
 	close(done)
 }
diff --git a/standard_renderer.go b/standard_renderer.go
index 54c0f5d..17b44e2 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -38,6 +38,9 @@ type standardRenderer struct {
 	useANSICompressor  bool
 	once               sync.Once
 
+	// cursor visibility state
+	cursorHidden bool
+
 	// essentially whether or not we're using the full size of the terminal
 	altScreenActive bool
 
@@ -299,6 +302,15 @@ func (r *standardRenderer) enterAltScreen() {
 	r.out.ClearScreen()
 	r.out.MoveCursor(1, 1)
 
+	// cmd.exe and other terminals keep separate cursor states for the AltScreen
+	// and the main buffer. We have to explicitly reset the cursor visibility
+	// whenever we enter AltScreen.
+	if r.cursorHidden {
+		r.out.HideCursor()
+	} else {
+		r.out.ShowCursor()
+	}
+
 	r.repaint()
 }
 
@@ -313,6 +325,15 @@ func (r *standardRenderer) exitAltScreen() {
 	r.altScreenActive = false
 	r.out.ExitAltScreen()
 
+	// cmd.exe and other terminals keep separate cursor states for the AltScreen
+	// and the main buffer. We have to explicitly reset the cursor visibility
+	// whenever we exit AltScreen.
+	if r.cursorHidden {
+		r.out.HideCursor()
+	} else {
+		r.out.ShowCursor()
+	}
+
 	r.repaint()
 }
 
@@ -320,6 +341,7 @@ func (r *standardRenderer) showCursor() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
+	r.cursorHidden = false
 	r.out.ShowCursor()
 }
 
@@ -327,6 +349,7 @@ func (r *standardRenderer) hideCursor() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
+	r.cursorHidden = true
 	r.out.HideCursor()
 }
 
diff --git a/tea.go b/tea.go
index d6a19d5..0c29a86 100644
--- a/tea.go
+++ b/tea.go
@@ -24,7 +24,7 @@ import (
 	isatty "github.com/mattn/go-isatty"
 	"github.com/muesli/cancelreader"
 	"github.com/muesli/termenv"
-	"golang.org/x/term"
+	"golang.org/x/sync/errgroup"
 )
 
 // ErrProgramKilled is returned by [Program.Run] when the program got killed.
@@ -142,14 +142,19 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 		msgs:         make(chan Msg),
 	}
 
-	// Initialize context and teardown channel.
-	p.ctx, p.cancel = context.WithCancel(context.Background())
-
 	// Apply all options to the program.
 	for _, opt := range opts {
 		opt(p)
 	}
 
+	// A context can be provided with a ProgramOption, but if none was provided
+	// we'll use the default background context.
+	if p.ctx == nil {
+		p.ctx = context.Background()
+	}
+	// Initialize context and teardown channel.
+	p.ctx, p.cancel = context.WithCancel(p.ctx)
+
 	// if no output was set, set it to stdout
 	if p.output == nil {
 		p.output = termenv.DefaultOutput()
@@ -205,20 +210,10 @@ func (p *Program) handleResize() chan struct{} {
 
 	if f, ok := p.output.TTY().(*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 {
-				p.errs <- err
-			}
-
-			select {
-			case <-p.ctx.Done():
-			case p.msgs <- WindowSizeMsg{w, h}:
-			}
-		}()
+		go p.checkResize()
 
 		// Listen for window resizes.
-		go listenForResize(p.ctx, f, p.msgs, p.errs, ch)
+		go p.listenForResize(ch)
 	} else {
 		close(ch)
 	}
@@ -306,7 +301,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				// NB: this blocks.
 				p.exec(msg.cmd, msg.fn)
 
-			case batchMsg:
+			case BatchMsg:
 				for _, cmd := range msg {
 					cmds <- cmd
 				}
@@ -316,7 +311,25 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				go func() {
 					// Execute commands one at a time, in order.
 					for _, cmd := range msg {
-						p.Send(cmd())
+						if cmd == nil {
+							continue
+						}
+						msg := cmd()
+						if batchMsg, ok := msg.(BatchMsg); ok {
+							g, _ := errgroup.WithContext(p.ctx)
+							for _, cmd := range batchMsg {
+								cmd := cmd
+								g.Go(func() error {
+									p.Send(cmd())
+									return nil
+								})
+							}
+							//nolint:errcheck
+							g.Wait() // wait for all commands from batch msg to finish
+							continue
+						} else {
+							p.Send(msg)
+						}
 					}
 				}()
 			}
@@ -351,6 +364,7 @@ func (p *Program) Run() (Model, error) {
 		if err != nil {
 			return p.initialModel, err
 		}
+		defer f.Close() //nolint:errcheck
 		p.input = f
 
 	case !p.startupOptions.has(withCustomInput):
@@ -370,11 +384,8 @@ func (p *Program) Run() (Model, error) {
 		if err != nil {
 			return p.initialModel, err
 		}
-		p.input = f
-	}
-
-	if f, ok := p.input.(io.ReadCloser); ok {
 		defer f.Close() //nolint:errcheck
+		p.input = f
 	}
 
 	// Handle signals.
@@ -442,7 +453,6 @@ func (p *Program) Run() (Model, error) {
 		if err := p.initCancelReader(); err != nil {
 			return model, err
 		}
-		defer p.cancelReader.Close() //nolint:errcheck
 	}
 
 	// Handle resize events.
@@ -464,9 +474,13 @@ func (p *Program) Run() (Model, error) {
 	// Tear down.
 	p.cancel()
 
-	// Wait for input loop to finish.
-	if p.cancelReader.Cancel() {
-		p.waitForReadLoop()
+	// Check if the cancel reader has been setup before waiting and closing.
+	if p.cancelReader != nil {
+		// Wait for input loop to finish.
+		if p.cancelReader.Cancel() {
+			p.waitForReadLoop()
+		}
+		_ = p.cancelReader.Close()
 	}
 
 	// Wait for all handlers to finish.
@@ -577,6 +591,12 @@ func (p *Program) RestoreTerminal() error {
 		go p.Send(repaintMsg{})
 	}
 
+	// If the output is a terminal, it may have been resized while another
+	// process was at the foreground, in which case we may not have received
+	// SIGWINCH. Detect any size change now and propagate the new size as
+	// needed.
+	go p.checkResize()
+
 	return nil
 }
 
diff --git a/tty.go b/tty.go
index 4f33d86..3ab6639 100644
--- a/tty.go
+++ b/tty.go
@@ -3,9 +3,12 @@ package tea
 import (
 	"errors"
 	"io"
+	"os"
 	"time"
 
+	isatty "github.com/mattn/go-isatty"
 	"github.com/muesli/cancelreader"
+	"golang.org/x/term"
 )
 
 func (p *Program) initTerminal() error {
@@ -76,7 +79,10 @@ func (p *Program) readLoop() {
 		msgs, err := readInputs(p.cancelReader)
 		if err != nil {
 			if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
-				p.errs <- err
+				select {
+				case <-p.ctx.Done():
+				case p.errs <- err:
+				}
 			}
 
 			return
@@ -98,3 +104,28 @@ func (p *Program) waitForReadLoop() {
 		// though it was not able to cancel the read.
 	}
 }
+
+// checkResize detects the current size of the output and informs the program
+// via a WindowSizeMsg.
+func (p *Program) checkResize() {
+	f, ok := p.output.TTY().(*os.File)
+	if !ok || !isatty.IsTerminal(f.Fd()) {
+		// can't query window size
+		return
+	}
+
+	w, h, err := term.GetSize(int(f.Fd()))
+	if err != nil {
+		select {
+		case <-p.ctx.Done():
+		case p.errs <- err:
+		}
+
+		return
+	}
+
+	p.Send(WindowSizeMsg{
+		Width:  w,
+		Height: h,
+	})
+}
diff --git a/tutorials/basics/README.md b/tutorials/basics/README.md
index 1b40823..8e1a26d 100644
--- a/tutorials/basics/README.md
+++ b/tutorials/basics/README.md
@@ -64,7 +64,7 @@ func initialModel() model {
 		choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
 
 		// A map which indicates which choices are selected. We're using
-		// the  map like a mathematical set. The keys refer to the indexes
+		// the map like a mathematical set. The keys refer to the indexes
 		// of the `choices` slice, above.
 		selected: make(map[int]struct{}),
 	}
@@ -154,7 +154,7 @@ the Bubble Tea runtime to quit, exiting the program.
 
 At last, it’s time to render our UI. Of all the methods, the view is the
 simplest. We look at the model in its current state and use it to return
-a `string`.  That string is our UI!
+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
@@ -230,8 +230,8 @@ there are [Go Docs][docs].
 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)
+* [The Fediverse](https://mastodon.social/@charmcli)
+* [Discord](https://charm.sh/chat)
 
 ***
 
diff --git a/tutorials/basics/main.go b/tutorials/basics/main.go
index 0c5b573..4970290 100644
--- a/tutorials/basics/main.go
+++ b/tutorials/basics/main.go
@@ -18,7 +18,7 @@ func initialModel() model {
 		choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
 
 		// A map which indicates which choices are selected. We're using
-		// the  map like a mathematical set. The keys refer to the indexes
+		// the map like a mathematical set. The keys refer to the indexes
 		// of the `choices` slice, above.
 		selected: make(map[int]struct{}),
 	}
diff --git a/tutorials/commands/README.md b/tutorials/commands/README.md
index 661f83b..701f1e2 100644
--- a/tutorials/commands/README.md
+++ b/tutorials/commands/README.md
@@ -124,7 +124,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
     case tea.KeyMsg:
         // Ctrl+c exits. Even with short running programs it's good to have
-        // a quit key, just incase your logic is off. Users will be very
+        // a quit key, just in case your logic is off. Users will be very
         // annoyed if they can't exit.
         if msg.Type == tea.KeyCtrlC {
             return m, tea.Quit
@@ -176,7 +176,7 @@ func main() {
 }
 ```
 
-And that's that. There's one more thing you that is helpful to know about
+And that's that. There's one more thing that is helpful to know about
 `Cmd`s, though.
 
 ## One More Thing About Commands
@@ -235,8 +235,8 @@ And, of course, check out the [Go Docs][docs].
 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)
+* [The Fediverse](https://mastodon.social/@charmcli)
+* [Discord](https://charm.sh/chat)
 
 ***
 
diff --git a/tutorials/go.mod b/tutorials/go.mod
index 9f0713f..0074938 100644
--- a/tutorials/go.mod
+++ b/tutorials/go.mod
@@ -1,7 +1,25 @@
 module tutorial
 
-go 1.16
+go 1.17
 
-require github.com/charmbracelet/bubbletea v0.21.0
+require github.com/charmbracelet/bubbletea v0.23.2
+
+require (
+	github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
+	github.com/containerd/console v1.0.3 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.17 // indirect
+	github.com/mattn/go-localereader v0.0.1 // indirect
+	github.com/mattn/go-runewidth v0.0.14 // indirect
+	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
+	github.com/muesli/reflow v0.3.0 // indirect
+	github.com/muesli/termenv v0.14.0 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
+	golang.org/x/sync v0.1.0 // indirect
+	golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
+	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
+	golang.org/x/text v0.3.8 // indirect
+)
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/tutorials/go.sum b/tutorials/go.sum
index 76e0059..e915ca8 100644
--- a/tutorials/go.sum
+++ b/tutorials/go.sum
@@ -1,11 +1,11 @@
-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/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E=
+github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -18,18 +18,40 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
 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.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
-github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
+github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0=
+github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 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-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=


Test patch:
diff --git a/commands_test.go b/commands_test.go
index 9caf18e..0ec1419 100644
--- a/commands_test.go
+++ b/commands_test.go
@@ -94,13 +94,13 @@ func TestBatch(t *testing.T) {
 	})
 	t.Run("single cmd", func(t *testing.T) {
 		b := Batch(Quit)()
-		if l := len(b.(batchMsg)); l != 1 {
+		if l := len(b.(BatchMsg)); l != 1 {
 			t.Fatalf("expected a []Cmd with len 1, got %d", l)
 		}
 	})
 	t.Run("mixed nil cmds", func(t *testing.T) {
 		b := Batch(nil, Quit, nil, Quit, nil, nil)()
-		if l := len(b.(batchMsg)); l != 2 {
+		if l := len(b.(BatchMsg)); l != 2 {
 			t.Fatalf("expected a []Cmd with len 2, got %d", l)
 		}
 	})
diff --git a/screen_test.go b/screen_test.go
index 33b5169..2f305e3 100644
--- a/screen_test.go
+++ b/screen_test.go
@@ -19,12 +19,12 @@ func TestClearMsg(t *testing.T) {
 		{
 			name:     "altscreen",
 			cmds:     []Cmd{EnterAltScreen, ExitAltScreen},
-			expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?1049lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l",
+			expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l",
 		},
 		{
 			name:     "altscreen_autoexit",
 			cmds:     []Cmd{EnterAltScreen},
-			expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1Hsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1049l",
+			expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25lsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1049l\x1b[?25h",
 		},
 		{
 			name:     "mouse_cellmotion",
diff --git a/tea_test.go b/tea_test.go
index 4b087bd..748b4cb 100644
--- a/tea_test.go
+++ b/tea_test.go
@@ -2,6 +2,7 @@ package tea
 
 import (
 	"bytes"
+	"context"
 	"sync/atomic"
 	"testing"
 	"time"
@@ -97,6 +98,28 @@ func TestTeaKill(t *testing.T) {
 	}
 }
 
+func TestTeaContext(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	p := NewProgram(m, WithContext(ctx), WithInput(&in), WithOutput(&buf))
+	go func() {
+		for {
+			time.Sleep(time.Millisecond)
+			if m.executed.Load() != nil {
+				cancel()
+				return
+			}
+		}
+	}()
+
+	if _, err := p.Run(); err != ErrProgramKilled {
+		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
+	}
+}
+
 func TestTeaBatchMsg(t *testing.T) {
 	var buf bytes.Buffer
 	var in bytes.Buffer
@@ -108,7 +131,7 @@ func TestTeaBatchMsg(t *testing.T) {
 	m := &testModel{}
 	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
 	go func() {
-		p.Send(batchMsg{inc, inc})
+		p.Send(BatchMsg{inc, inc})
 
 		for {
 			time.Sleep(time.Millisecond)
@@ -150,6 +173,30 @@ func TestTeaSequenceMsg(t *testing.T) {
 	}
 }
 
+func TestTeaSequenceMsgWithBatchMsg(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	inc := func() Msg {
+		return incrementMsg{}
+	}
+	batch := func() Msg {
+		return BatchMsg{inc, inc}
+	}
+
+	m := &testModel{}
+	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
+	go p.Send(sequenceMsg{batch, inc, Quit})
+
+	if _, err := p.Run(); err != nil {
+		t.Fatal(err)
+	}
+
+	if m.counter.Load() != 3 {
+		t.Fatalf("counter should be 3, got %d", m.counter.Load())
+	}
+}
+
 func TestTeaSend(t *testing.T) {
 	var buf bytes.Buffer
 	var in bytes.Buffer