Repository: charmbracelet/bubbletea
Pull request #536 (2022-10-13 05:35:53)

feat: add generic event filter
`WithFilter` lets you supply an event filter that will be invoked before Bubble Tea processes a `tea.Msg`. The event filter can return any `tea.Msg` which will then get handled by Bubble Tea instead of the original event. If the event filter returns `nil`, the event will be ignored and Bubble Tea will not process it.

As an example, this could be used to prevent a program from shutting down if there are unsaved changes.

Based on the fantastic work by @aschey and supersedes https://github.com/charmbracelet/bubbletea/pull/521.

Might make #114 more relevant.
Resolves https://github.com/charmbracelet/bubbletea/issues/472.


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 0000000..a9b0770
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,61 @@
+name: Bug Report
+description: File a bug report
+labels: [bug]
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to fill out this bug report! Please fill the form below.
+  - type: textarea
+    id: what-happened
+    attributes:
+      label: What happened?
+      description: Also tell us, what did you expect to happen?
+    validations:
+      required: true
+  - type: textarea
+    id: reproducible
+    attributes:
+      label: How can we reproduce this?
+      description: |
+        Please share a code snippet, gist, or public repository that reproduces the issue.
+        Make sure to make the reproducible as concise as possible,
+        with only the minimum required code to reproduce the issue.
+    validations:
+      required: true
+  - type: textarea
+    id: version
+    attributes:
+      label: Which version of bubbletea are you using?
+      description: ''
+      render: bash
+    validations:
+      required: true
+  - type: textarea
+    id: terminaal
+    attributes:
+      label: Which terminals did you reproduce this with?
+      description: |
+        Other helpful information:
+        was it over SSH?
+        On tmux?
+        Which version of said terminal?
+    validations:
+      required: true
+  - type: checkboxes
+    id: search
+    attributes:
+      label: Search
+      options:
+        - label: |
+           I searched for other open and closed issues and pull requests before opening this,
+           and didn't find anything that seems related.
+          required: true
+  - type: textarea
+    id: ctx
+    attributes:
+      label: Additional context
+      description: Anything else you would like to add
+    validations:
+      required: false
+
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..0e7a6cb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,37 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**Setup**
+Please complete the following information along with version numbers, if applicable.
+ - OS [e.g. Ubuntu, macOS]
+ - Shell [e.g. zsh, fish]
+ - Terminal Emulator [e.g. kitty, iterm]
+ - Terminal Multiplexer [e.g. tmux]
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Source Code**
+Please include source code if needed to reproduce the behavior. 
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+Add screenshots to help explain your problem.
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..897a394
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+- name: Discord
+  url: https://charm.sh/discord
+  about: Chat on our Discord.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..11fc491
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
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..cef3f37 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,14 +5,14 @@ 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:
       GO111MODULE: "on"
     steps:
       - name: Install Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v4
         with:
           go-version: ${{ matrix.go-version }}
 
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index bcc6721..845096e 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -12,7 +12,7 @@ jobs:
       GO111MODULE: "on"
     steps:
       - name: Install Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v4
         with:
           go-version: ${{ matrix.go-version }}
 
diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml
new file mode 100644
index 0000000..f027cf2
--- /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@v4
+        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/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml
index 77348b9..6230528 100644
--- a/.github/workflows/lint-soft.yml
+++ b/.github/workflows/lint-soft.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Install Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v4
         with:
           go-version: ^1
 
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 1129ece..2f26b2a 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Install Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v4
         with:
           go-version: ^1
 
diff --git a/README.md b/README.md
index 490e252..0a88caa 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,70 @@ 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
+* [countdown](https://github.com/aldernero/countdown): a multi-event countdown timer
 * [dns53](https://github.com/purpleclay/dns53): dynamic DNS with Amazon Route53. Expose your EC2 quickly, securely and privately
+* [eks-node-viewer](https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an eks cluster
+* [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
+* [WG Commander](https://github.com/AndrianBdn/wg-cmd): a TUI for a simple WireGuard VPN setup 
 * [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 +396,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/file-picker/main.go b/examples/file-picker/main.go
new file mode 100644
index 0000000..30ecf01
--- /dev/null
+++ b/examples/file-picker/main.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/filepicker"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+type model struct {
+	filepicker   filepicker.Model
+	selectedFile string
+	quitting     bool
+}
+
+func (m model) Init() tea.Cmd {
+	return m.filepicker.Init()
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c", "q":
+			m.quitting = true
+			return m, tea.Quit
+		}
+	}
+
+	var cmd tea.Cmd
+	m.filepicker, cmd = m.filepicker.Update(msg)
+
+	// Did the user select a file?
+	if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
+		// Get the path of the selected file.
+		m.selectedFile = path
+	}
+
+	return m, cmd
+}
+
+func (m model) View() string {
+	if m.quitting {
+		return ""
+	}
+	var s strings.Builder
+	s.WriteString("\n  ")
+	if m.selectedFile == "" {
+		s.WriteString("Pick a file:")
+	} else {
+		s.WriteString("Selected file: " + m.filepicker.Styles.Selected.Render(m.selectedFile))
+	}
+	s.WriteString("\n\n" + m.filepicker.View() + "\n")
+	return s.String()
+}
+
+func main() {
+	fp := filepicker.New()
+	fp.Path, _ = os.UserHomeDir()
+
+	m := model{
+		filepicker: fp,
+	}
+	tm, _ := tea.NewProgram(&m, tea.WithOutput(os.Stderr)).Run()
+	mm := tm.(model)
+	fmt.Println("\n  You selected: " + m.filepicker.Styles.Selected.Render(mm.selectedFile) + "\n")
+}
diff --git a/examples/go.mod b/examples/go.mod
index 042b22f..1183e5e 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -1,17 +1,44 @@
 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/lipgloss v0.6.0
+	github.com/charmbracelet/bubbles v0.15.1-0.20230306155959-3372cf1aea2b
+	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.7.1
+	github.com/dustin/go-humanize v1.0.1
 	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.18
 	github.com/muesli/reflow v0.3.0
-	github.com/muesli/termenv v0.13.0
+	github.com/muesli/termenv v0.15.1
+)
+
+require (
+	github.com/alecthomas/chroma v0.10.0 // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/aymerick/douceur v0.2.0 // indirect
+	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // 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.6.0 // indirect
+	golang.org/x/term v0.6.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..cce9745 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -2,26 +2,31 @@ 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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 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.1-0.20230306155959-3372cf1aea2b h1:K9dWJ2spDhDhIrqnchjG867djPxWWe3mwdk6RdLMfhg=
+github.com/charmbracelet/bubbles v0.15.1-0.20230306155959-3372cf1aea2b/go.mod h1:39HL8bnL0foloiENA/KvD+3mNg5SqWQV2Qh3eY/4ey4=
+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=
-github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
+github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 h1:VRIbnDWRmAh5yBdz+J6yFMF5vso1It6vn+WmM/5l7MA=
 github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOfxzWM9Cst40msBF1C2UdQgDv962oTxSuMs=
 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
@@ -30,10 +35,11 @@ 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/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
+github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 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 +48,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 +57,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.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
+github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
 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 +74,52 @@ 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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.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/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+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/list-simple/main.go b/examples/list-simple/main.go
index 9208d05..8cc4290 100644
--- a/examples/list-simple/main.go
+++ b/examples/list-simple/main.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
@@ -40,8 +41,8 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 
 	fn := itemStyle.Render
 	if index == m.Index() {
-		fn = func(s string) string {
-			return selectedItemStyle.Render("> " + s)
+		fn = func(s ...string) string {
+			return selectedItemStyle.Render("> " + strings.Join(s, " "))
 		}
 	}
 
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/prevent-quit/main.go b/examples/prevent-quit/main.go
new file mode 100644
index 0000000..1339393
--- /dev/null
+++ b/examples/prevent-quit/main.go
@@ -0,0 +1,154 @@
+package main
+
+// A program demonstrating how to use the WithFilter option to intercept events.
+
+import (
+	"fmt"
+	"log"
+
+	"github.com/charmbracelet/bubbles/help"
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/textarea"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	choiceStyle   = lipgloss.NewStyle().PaddingLeft(1).Foreground(lipgloss.Color("241"))
+	saveTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170"))
+	quitViewStyle = lipgloss.NewStyle().Padding(1).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("170"))
+)
+
+func main() {
+	p := tea.NewProgram(initialModel(), tea.WithFilter(filter))
+
+	if _, err := p.Run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func filter(teaModel tea.Model, msg tea.Msg) tea.Msg {
+	if _, ok := msg.(tea.QuitMsg); !ok {
+		return msg
+	}
+
+	m := teaModel.(model)
+	if m.hasChanges {
+		return nil
+	}
+
+	return msg
+}
+
+type model struct {
+	textarea   textarea.Model
+	help       help.Model
+	keymap     keymap
+	saveText   string
+	hasChanges bool
+	quitting   bool
+}
+
+type keymap struct {
+	save key.Binding
+	quit key.Binding
+}
+
+func initialModel() model {
+	ti := textarea.New()
+	ti.Placeholder = "Only the best words"
+	ti.Focus()
+
+	return model{
+		textarea: ti,
+		help:     help.NewModel(),
+		keymap: keymap{
+			save: key.NewBinding(
+				key.WithKeys("ctrl+s"),
+				key.WithHelp("ctrl+s", "save"),
+			),
+			quit: key.NewBinding(
+				key.WithKeys("esc", "ctrl+c"),
+				key.WithHelp("esc", "quit"),
+			),
+		},
+	}
+}
+
+func (m model) Init() tea.Cmd {
+	return textarea.Blink
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	if m.quitting {
+		return m.updatePromptView(msg)
+	}
+
+	return m.updateTextView(msg)
+}
+
+func (m model) updateTextView(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	var cmd tea.Cmd
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		m.saveText = ""
+		switch {
+		case key.Matches(msg, m.keymap.save):
+			m.saveText = "Changes saved!"
+			m.hasChanges = false
+		case key.Matches(msg, m.keymap.quit):
+			m.quitting = true
+			return m, tea.Quit
+		case msg.Type == tea.KeyRunes:
+			m.saveText = ""
+			m.hasChanges = true
+			fallthrough
+		default:
+			if !m.textarea.Focused() {
+				cmd = m.textarea.Focus()
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+	m.textarea, cmd = m.textarea.Update(msg)
+	cmds = append(cmds, cmd)
+	return m, tea.Batch(cmds...)
+}
+
+func (m model) updatePromptView(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		// For simplicity's sake, we'll treat any key besides "y" as "no"
+		if key.Matches(msg, m.keymap.quit) || msg.String() == "y" {
+			m.hasChanges = false
+			return m, tea.Quit
+		}
+		m.quitting = false
+	}
+
+	return m, nil
+}
+
+func (m model) View() string {
+	if m.quitting {
+		if m.hasChanges {
+			text := lipgloss.JoinHorizontal(lipgloss.Top, "You have unsaved changes. Quit without saving?", choiceStyle.Render("[yn]"))
+			return quitViewStyle.Render(text)
+		}
+		return "Very important, thank you\n"
+	}
+
+	helpView := m.help.ShortHelpView([]key.Binding{
+		m.keymap.save,
+		m.keymap.quit,
+	})
+
+	return fmt.Sprintf(
+		"\nType some important things.\n\n%s\n\n %s\n %s",
+		m.textarea.View(),
+		saveTextStyle.Render(m.saveText),
+		helpView,
+	) + "\n\n"
+}
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..86f9067 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/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81
+	github.com/mattn/go-isatty v0.0.18
 	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
-	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
-	golang.org/x/text v0.3.7 // indirect
+	github.com/muesli/termenv v0.15.1
+	golang.org/x/sync v0.1.0
+	golang.org/x/term v0.6.0
+)
+
+require (
+	github.com/aymanbagabas/go-osc52/v2 v2.0.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.6.0 // indirect
+	golang.org/x/text v0.3.8 // indirect
 )
diff --git a/go.sum b/go.sum
index 76e0059..77ace75 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,12 @@
-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/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
-github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 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/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
+github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 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 +19,42 @@ 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.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
+github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+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-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-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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.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/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+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/logging.go b/logging.go
index 97ffcb7..59258d4 100644
--- a/logging.go
+++ b/logging.go
@@ -1,6 +1,7 @@
 package tea
 
 import (
+	"io"
 	"log"
 	"os"
 	"unicode"
@@ -19,6 +20,18 @@ import (
 //	  }
 //	  defer f.Close()
 func LogToFile(path string, prefix string) (*os.File, error) {
+	return LogToFileWith(path, prefix, log.Default())
+}
+
+// LogOptionsSetter is an interface implemented by stdlib's log and charm's log
+// libraries.
+type LogOptionsSetter interface {
+	SetOutput(io.Writer)
+	SetPrefix(string)
+}
+
+// LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter.
+func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) {
 	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
 	if err != nil {
 		return nil, err
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/nil_renderer.go b/nil_renderer.go
index a022636..f5637aa 100644
--- a/nil_renderer.go
+++ b/nil_renderer.go
@@ -5,7 +5,7 @@ type nilRenderer struct{}
 func (n nilRenderer) start()                  {}
 func (n nilRenderer) stop()                   {}
 func (n nilRenderer) kill()                   {}
-func (n nilRenderer) write(v string)          {}
+func (n nilRenderer) write(_ string)          {}
 func (n nilRenderer) repaint()                {}
 func (n nilRenderer) clearScreen()            {}
 func (n nilRenderer) altScreen() bool         { return false }
diff --git a/options.go b/options.go
index 59d0685..17d05ba 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,10 +144,45 @@ 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) {
 		p.startupOptions |= withANSICompressor
 	}
 }
+
+// WithFilter supplies an event filter that will be invoked before Bubble Tea
+// processes a tea.Msg. The event filter can return any tea.Msg which will then
+// get handled by Bubble Tea instead of the original event. If the event filter
+// returns nil, the event will be ignored and Bubble Tea will not process it.
+//
+// As an example, this could be used to prevent a program from shutting down if
+// there are unsaved changes.
+//
+// Example:
+//
+//	func filter(m tea.Model, msg tea.Msg) tea.Msg {
+//		if _, ok := msg.(tea.QuitMsg); !ok {
+//			return msg
+//		}
+//
+//		model := m.(myModel)
+//		if model.hasChanges {
+//			return nil
+//		}
+//
+//		return msg
+//	}
+//
+//	p := tea.NewProgram(Model{}, tea.WithFilter(filter));
+//
+//	if _,err := p.Run(); err != nil {
+//		fmt.Println("Error running program:", err)
+//		os.Exit(1)
+//	}
+func WithFilter(filter func(Model, Msg) Msg) ProgramOption {
+	return func(p *Program) {
+		p.filter = filter
+	}
+}
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 7d68b41..e4fb61c 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
 
@@ -55,6 +58,7 @@ func newRenderer(out *termenv.Output, useANSICompressor bool) renderer {
 	r := &standardRenderer{
 		out:                out,
 		mtx:                &sync.Mutex{},
+		done:               make(chan struct{}),
 		framerate:          defaultFramerate,
 		useANSICompressor:  useANSICompressor,
 		queuedMessageLines: []string{},
@@ -69,8 +73,16 @@ func newRenderer(out *termenv.Output, useANSICompressor bool) renderer {
 func (r *standardRenderer) start() {
 	if r.ticker == nil {
 		r.ticker = time.NewTicker(r.framerate)
+	} else {
+		// If the ticker already exists, it has been stopped and we need to
+		// reset it.
+		r.ticker.Reset(r.framerate)
 	}
-	r.done = make(chan struct{})
+
+	// Since the renderer can be restarted after a stop, we need to reset
+	// the done channel and its corresponding sync.Once.
+	r.once = sync.Once{}
+
 	go r.listen()
 }
 
@@ -84,7 +96,7 @@ func (r *standardRenderer) stop() {
 
 	r.out.ClearLine()
 	r.once.Do(func() {
-		close(r.done)
+		r.done <- struct{}{}
 	})
 
 	if r.useANSICompressor {
@@ -101,7 +113,7 @@ func (r *standardRenderer) kill() {
 
 	r.out.ClearLine()
 	r.once.Do(func() {
-		close(r.done)
+		r.done <- struct{}{}
 	})
 }
 
@@ -109,14 +121,12 @@ func (r *standardRenderer) kill() {
 func (r *standardRenderer) listen() {
 	for {
 		select {
-		case <-r.ticker.C:
-			if r.ticker != nil {
-				r.flush()
-			}
 		case <-r.done:
 			r.ticker.Stop()
-			r.ticker = nil
 			return
+
+		case <-r.ticker.C:
+			r.flush()
 		}
 	}
 }
@@ -136,6 +146,15 @@ func (r *standardRenderer) flush() {
 	out := termenv.NewOutput(buf)
 
 	newLines := strings.Split(r.buf.String(), "\n")
+
+	// If we know the output's height, we can use it to determine how many
+	// lines we can render. We drop lines from the top of the render buffer if
+	// necessary, as we can't navigate the cursor into the terminal's scrollback
+	// buffer.
+	if r.height > 0 && len(newLines) > r.height {
+		newLines = newLines[len(newLines)-r.height:]
+	}
+
 	numLinesThisFlush := len(newLines)
 	oldLines := strings.Split(r.lastRender, "\n")
 	skipLines := make(map[int]struct{})
@@ -290,6 +309,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()
 }
 
@@ -304,6 +332,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()
 }
 
@@ -311,6 +348,7 @@ func (r *standardRenderer) showCursor() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
+	r.cursorHidden = false
 	r.out.ShowCursor()
 }
 
@@ -318,6 +356,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 b94ca4e..f5ae4e9 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.
@@ -123,16 +123,18 @@ type Program struct {
 	// as this value only comes into play on Windows, hence the ignore comment
 	// below.
 	windowsStdin *os.File //nolint:golint,structcheck,unused
+
+	filter func(Model, Msg) Msg
 }
 
 // Quit is a special command that tells the Bubble Tea program to exit.
 func Quit() Msg {
-	return quitMsg{}
+	return QuitMsg{}
 }
 
-// quitMsg in an internal message signals that the program should quit. You can
-// send a quitMsg with Quit.
-type quitMsg struct{}
+// QuitMsg signals that the program should quit. You can send a QuitMsg with
+// Quit.
+type QuitMsg struct{}
 
 // NewProgram creates a new Program.
 func NewProgram(model Model, opts ...ProgramOption) *Program {
@@ -147,6 +149,14 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 		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()
@@ -186,7 +196,7 @@ func (p *Program) handleSignals() chan struct{} {
 
 			case <-sig:
 				if !p.ignoreSignals {
-					p.msgs <- quitMsg{}
+					p.msgs <- QuitMsg{}
 					return
 				}
 			}
@@ -202,20 +212,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)
 	}
@@ -247,10 +247,8 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
 				// possible to cancel them so we'll have to leak the goroutine
 				// until Cmd returns.
 				go func() {
-					select {
-					case p.msgs <- cmd():
-					case <-p.ctx.Done():
-					}
+					msg := cmd() // this can be long.
+					p.Send(msg)
 				}()
 			}
 		}
@@ -271,9 +269,17 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 			return model, err
 
 		case msg := <-p.msgs:
+			// Filter messages.
+			if p.filter != nil {
+				msg = p.filter(model, msg)
+			}
+			if msg == nil {
+				continue
+			}
+
 			// Handle special internal messages.
 			switch msg := msg.(type) {
-			case quitMsg:
+			case QuitMsg:
 				return model, nil
 
 			case clearScreenMsg:
@@ -305,7 +311,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
 				}
@@ -315,10 +321,27 @@ 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 {
-						select {
-						case p.msgs <- cmd():
-						case <-p.ctx.Done():
+						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
 						}
+
+						p.Send(msg)
 					}
 				}()
 			}
@@ -344,7 +367,6 @@ func (p *Program) Run() (Model, error) {
 	cmds := make(chan Cmd)
 	p.errs = make(chan error)
 
-	p.ctx, p.cancel = context.WithCancel(context.Background())
 	defer p.cancel()
 
 	switch {
@@ -354,6 +376,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):
@@ -373,11 +396,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.
@@ -445,7 +465,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.
@@ -459,14 +478,21 @@ func (p *Program) Run() (Model, error) {
 	killed := p.ctx.Err() != nil
 	if killed {
 		err = ErrProgramKilled
+	} else {
+		// Ensure we rendered the final state of the model.
+		p.renderer.write(model.View())
 	}
 
 	// 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.
@@ -501,10 +527,14 @@ func (p *Program) Start() error {
 // messages to be injected from outside the program for interoperability
 // purposes.
 //
-// If the program is not running this will be a no-op, so it's safe to
-// send messages if the program is unstarted, or has exited.
+// If the program hasn't started yet this will be a blocking operation.
+// If the program has already been terminated this will be a no-op, so it's safe
+// to send messages after the program has exited.
 func (p *Program) Send(msg Msg) {
-	p.msgs <- msg
+	select {
+	case <-p.ctx.Done():
+	case p.msgs <- msg:
+	}
 }
 
 // Quit is a convenience function for quitting Bubble Tea programs. Use it
@@ -549,6 +579,10 @@ func (p *Program) ReleaseTerminal() error {
 	p.cancelReader.Cancel()
 	p.waitForReadLoop()
 
+	if p.renderer != nil {
+		p.renderer.stop()
+	}
+
 	p.altScreenWasActive = p.renderer.altScreen()
 	return p.restoreTerminalState()
 }
@@ -572,6 +606,15 @@ func (p *Program) RestoreTerminal() error {
 		// entering alt screen already causes a repaint.
 		go p.Send(repaintMsg{})
 	}
+	if p.renderer != nil {
+		p.renderer.start()
+	}
+
+	// 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..5933d12 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/v2 v2.0.1 // indirect
+	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.18 // 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.15.1 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
+	golang.org/x/sync v0.1.0 // indirect
+	golang.org/x/sys v0.6.0 // indirect
+	golang.org/x/term v0.6.0 // 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..484e442 100644
--- a/tutorials/go.sum
+++ b/tutorials/go.sum
@@ -1,11 +1,14 @@
-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/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
-github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 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/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
+github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 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 +21,42 @@ 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.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
+github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+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-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-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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.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/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+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/options_test.go b/options_test.go
index 7e08f58..71a3c6c 100644
--- a/options_test.go
+++ b/options_test.go
@@ -35,6 +35,13 @@ func TestOptions(t *testing.T) {
 		}
 	})
 
+	t.Run("filter", func(t *testing.T) {
+		p := NewProgram(nil, WithFilter(func(_ Model, msg Msg) Msg { return msg }))
+		if p.filter == nil {
+			t.Errorf("expected filter to be set")
+		}
+	})
+
 	t.Run("startup options", func(t *testing.T) {
 		exercise := func(t *testing.T, opt ProgramOption, expect startupOptions) {
 			p := NewProgram(nil, opt)
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 c61c518..d65f0d7 100644
--- a/tea_test.go
+++ b/tea_test.go
@@ -2,6 +2,7 @@ package tea
 
 import (
 	"bytes"
+	"context"
 	"sync/atomic"
 	"testing"
 	"time"
@@ -76,6 +77,47 @@ func TestTeaQuit(t *testing.T) {
 	}
 }
 
+func TestTeaWithFilter(t *testing.T) {
+	testTeaWithFilter(t, 0)
+	testTeaWithFilter(t, 1)
+	testTeaWithFilter(t, 2)
+}
+
+func testTeaWithFilter(t *testing.T, preventCount uint32) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	shutdowns := uint32(0)
+	p := NewProgram(m,
+		WithInput(&in),
+		WithOutput(&buf),
+		WithFilter(func(_ Model, msg Msg) Msg {
+			if _, ok := msg.(QuitMsg); !ok {
+				return msg
+			}
+			if shutdowns < preventCount {
+				atomic.AddUint32(&shutdowns, 1)
+				return nil
+			}
+			return msg
+		}))
+
+	go func() {
+		for atomic.LoadUint32(&shutdowns) <= preventCount {
+			time.Sleep(time.Millisecond)
+			p.Quit()
+		}
+	}()
+
+	if err := p.Start(); err != nil {
+		t.Fatal(err)
+	}
+	if shutdowns != preventCount {
+		t.Errorf("Expected %d prevented shutdowns, got %d", preventCount, shutdowns)
+	}
+}
+
 func TestTeaKill(t *testing.T) {
 	var buf bytes.Buffer
 	var in bytes.Buffer
@@ -97,6 +139,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 +172,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)
@@ -149,3 +213,53 @@ func TestTeaSequenceMsg(t *testing.T) {
 		t.Fatalf("counter should be 2, got %d", m.counter.Load())
 	}
 }
+
+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
+
+	m := &testModel{}
+	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
+
+	// sending before the program is started is a blocking operation
+	go p.Send(Quit())
+
+	if _, err := p.Run(); err != nil {
+		t.Fatal(err)
+	}
+
+	// sending a message after program has quit is a no-op
+	p.Send(Quit())
+}
+
+func TestTeaNoRun(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	NewProgram(m, WithInput(&in), WithOutput(&buf))
+}