Repository: charmbracelet/bubbletea

Issue:
Nested `Sequence` breaks in versions >= `v1.3.7`
**Describe the bug**
Nested `Sequence` breaks in versions >= `v1.3.7`

**Setup**
Please complete the following information along with version numbers, if applicable.
 - macOS
 - fish
 - GhosTTY

**To Reproduce**
Run the below source code.

**Source Code**
```go
package main

import (
	"fmt"
	"time"

	tea "github.com/charmbracelet/bubbletea"
)

var cnt = 10
var c = make(chan int, cnt)

type message int
type model int

func main() {
	tea.NewProgram(model(0)).Run()
}

func (m *model) seqMaker() tea.Cmd {
	return tea.Sequence(
		func() tea.Msg {
			return message(<-c)
		},
	)
}

func (m model) Init() tea.Cmd {

	cmds := []tea.Cmd{}

	for i := 0; i < cnt; i++ {
		cmds = append(cmds, m.seqMaker())
		cmds = append(cmds, func() tea.Msg { return message(1) })
		go func() {
			time.Sleep(time.Millisecond * 500)
			c <- -1
		}()
	}

	return tea.Sequence(cmds...)
}

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":
			return m, tea.Quit
		}
	case message:
		m += model(msg)
		time.Sleep(time.Millisecond * 10)
		return m, nil
	}
	return m, nil
}

func (m model) View() string {
	return fmt.Sprintf("Count: %d\n", m)
}
```

**Expected behavior**
Count increases to `10`, then decreases to `0` after 0.5s.

**Screenshots**

## v1.3.6 (as expected)
![out](https://github.com/user-attachments/assets/7d8ef762-a81a-4e71-90ae-c7384940d384)

## v1.3.7 - v1.3.9
![out](https://github.com/user-attachments/assets/519b7946-97e2-4904-be8f-066edcd1b9a2)


**Additional context**
https://github.com/charmbracelet/bubbletea/pull/958#issuecomment-3271339213


Discussion:
@aymanbagabas: Thank you for opening this issue.

Bubble Tea runs its commands in separate goroutines so that you don't have to deal with channels and goroutines directly. I think the behavior before v1.3.7 was incorrect and it wouldn't wait for nested sequence commands to run sequentially.

Take a look at this example instead:

```go
package main

import (
	"fmt"
	"time"

	tea "github.com/charmbracelet/bubbletea"
)

const cnt = 10

type (
	message      int
	decrementMsg struct{}
	model        struct {
		count     int
		decrement bool
	}
)

func main() {
	tea.NewProgram(model{count: 0}).Run()
}

func (m model) tick() tea.Cmd {
	return tea.Tick(time.Millisecond*10, func(t time.Time) tea.Msg {
		if m.decrement {
			if m.count > 0 {
				return message(-1)
			}
			return nil
		} else {
			if m.count < cnt {
				return message(1)
			}
			return decrementMsg{}
		}
	})
}

func (m model) Init() tea.Cmd {
	return m.tick()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c", "q":
			return m, tea.Quit
		}
	case decrementMsg:
		m.decrement = true
		time.Sleep(time.Millisecond * 500)
		return m, m.tick()
	case message:
		m.count += int(msg)
		return m, m.tick()
	}
	return m, nil
}

func (m model) View() string {
	return fmt.Sprintf("Count: %d\n", m.count)
}
```@W-Floyd: Ah I got it now, so until now I was using a `Sequence` but it really was acting more like a `Batch` - i.e. I was relying on broken behavior. Thanks!

My real use-case requires listening to a channel from a library then and triggering an action. I've since moved the functionality around such that my waiting on a channel is handled by a `tea.Cmd` that Bubble Tea puts into a goroutine, which seems to work well. Many thanks!

Solution patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a3e7f1b..d28c064 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,36 +1,65 @@
 version: 2
+
 updates:
   - package-ecosystem: "gomod"
     directory: "/"
     schedule:
-      interval: "daily"
+      interval: "weekly"
+      day: "monday"
+      time: "05:00"
+      timezone: "America/New_York"
     labels:
       - "dependencies"
     commit-message:
       prefix: "chore"
       include: "scope"
-  - package-ecosystem: "gomod"
-    directory: "/examples"
+
+  - package-ecosystem: "github-actions"
+    directory: "/"
     schedule:
-      interval: "daily"
+      interval: "weekly"
+      day: "monday"
+      time: "05:00"
+      timezone: "America/New_York"
     labels:
       - "dependencies"
     commit-message:
       prefix: "chore"
       include: "scope"
+
+  - package-ecosystem: "docker"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "05:00"
+      timezone: "America/New_York"
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "chore"
+      include: "scope"
+
   - package-ecosystem: "gomod"
-    directory: "/tutorials"
+    directory: "/examples"
     schedule:
-      interval: "daily"
+      interval: "weekly"
+      day: "monday"
+      time: "05:00"
+      timezone: "America/New_York"
     labels:
       - "dependencies"
     commit-message:
       prefix: "chore"
       include: "scope"
-  - package-ecosystem: "github-actions"
-    directory: "/"
+
+  - package-ecosystem: "gomod"
+    directory: "/tutorials"
     schedule:
-      interval: "daily"
+      interval: "weekly"
+      day: "monday"
+      time: "05:00"
+      timezone: "America/New_York"
     labels:
       - "dependencies"
     commit-message:
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index d197e45..b657c63 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -20,9 +20,10 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Coverage
-        env:
-          COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         run: |
-          go test -race -covermode atomic -coverprofile=profile.cov ./...
-          go install github.com/mattn/goveralls@latest
-          goveralls -coverprofile=profile.cov -service=github
+          go test -race -covermode=atomic -coverprofile=coverage.txt ./...
+
+      - uses: codecov/codecov-action@v5
+        with:
+          file: ./coverage.txt
+          token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml
new file mode 100644
index 0000000..9b08259
--- /dev/null
+++ b/.github/workflows/dependabot-sync.yml
@@ -0,0 +1,17 @@
+name: dependabot-sync
+on:
+  schedule:
+    - cron: "0 0 * * 0" # every Sunday at midnight
+  workflow_dispatch: # allows manual triggering
+
+permissions:
+  contents: write
+  pull-requests: write
+
+jobs:
+  dependabot-sync:
+    uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main
+    with:
+      repo_name: ${{ github.event.repository.name }}
+    secrets:
+      gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml
index c1d9c31..a10a04d 100644
--- a/.github/workflows/examples.yml
+++ b/.github/workflows/examples.yml
@@ -29,7 +29,7 @@ jobs:
         run: |
           (cd ./examples && go mod tidy)
           (cd ./tutorials && go mod tidy)
-      - uses: stefanzweifel/git-auto-commit-action@v5
+      - uses: stefanzweifel/git-auto-commit-action@v6
         with:
           commit_message: "chore: go mod tidy tutorials and examples"
           branch: master
diff --git a/.gitignore b/.gitignore
index 9cc5235..abd7c06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ tutorials/basics/basics
 tutorials/commands/commands
 .idea
 coverage.txt
+dist/
diff --git a/.golangci-soft.yml b/.golangci-soft.yml
deleted file mode 100644
index 8783713..0000000
--- a/.golangci-soft.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-run:
-  tests: false
-
-issues:
-  include:
-    - EXC0001
-    - EXC0005
-    - EXC0011
-    - EXC0012
-    - EXC0013
-
-  max-issues-per-linter: 0
-  max-same-issues: 0
-
-linters:
-  enable:
-    - exhaustive
-    - goconst
-    - godot
-    - godox
-    - mnd
-    - gomoddirectives
-    - goprintffuncname
-    - misspell
-    - nakedret
-    - nestif
-    - noctx
-    - nolintlint
-    - prealloc
-    - wrapcheck
-
-  # disable default linters, they are already enabled in .golangci.yml
-  disable:
-    - errcheck
-    - gosimple
-    - govet
-    - ineffassign
-    - staticcheck
-    - typecheck
-    - unused
diff --git a/.golangci.yml b/.golangci.yml
index d6789e0..4fac29c 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,24 +1,22 @@
+version: "2"
 run:
   tests: false
-
-issues:
-  include:
-    - EXC0001
-    - EXC0005
-    - EXC0011
-    - EXC0012
-    - EXC0013
-
-  max-issues-per-linter: 0
-  max-same-issues: 0
-
 linters:
   enable:
     - bodyclose
-    - gofumpt
-    - goimports
+    - exhaustive
+    - goconst
+    - godot
+    - gomoddirectives
+    - goprintffuncname
     - gosec
+    - misspell
+    - nakedret
+    - nestif
     - nilerr
+    - noctx
+    - nolintlint
+    - prealloc
     - revive
     - rowserrcheck
     - sqlclosecheck
@@ -26,3 +24,17 @@ linters:
     - unconvert
     - unparam
     - whitespace
+    - wrapcheck
+  exclusions:
+    generated: lax
+    presets:
+      - common-false-positives
+issues:
+  max-issues-per-linter: 0
+  max-same-issues: 0
+formatters:
+  enable:
+    - gofumpt
+    - goimports
+  exclusions:
+    generated: lax
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 40d9f29..3353d02 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -1,6 +1,5 @@
+# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
+version: 2
 includes:
   - from_url:
       url: charmbracelet/meta/main/goreleaser-lib.yaml
-
-# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
-
diff --git a/LICENSE b/LICENSE
index 31d76c1..be3f0c1 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2020-2023 Charmbracelet, Inc
+Copyright (c) 2020-2025 Charmbracelet, Inc
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 4dbe75e..b3acf90 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,15 @@
 # Bubble Tea
 
 <p>
-    <a href="https://stuff.charm.sh/bubbletea/bubbletea-4k.png"><img src="https://github.com/charmbracelet/bubbletea/assets/25087/108d4fdb-d554-4910-abed-2a5f5586a60e" width="313" alt="Bubble Tea Title Treatment"></a><br>
+    <picture>
+      <source media="(prefers-color-scheme: light)" srcset="https://stuff.charm.sh/bubbletea/bubble-tea-v2-light.png" width="308">
+      <source media="(prefers-color-scheme: dark)" srcset="https://stuff.charm.sh/bubbletea/bubble-tea-v2-dark.png" width="312">
+      <img src="https://stuff.charm.sh/bubbletea/bubble-tea-v2-light.png" width="308" />
+    </picture>
+    <br>
     <a href="https://github.com/charmbracelet/bubbletea/releases"><img src="https://img.shields.io/github/release/charmbracelet/bubbletea.svg" alt="Latest Release"></a>
     <a href="https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc"><img src="https://godoc.org/github.com/charmbracelet/bubbletea?status.svg" alt="GoDoc"></a>
     <a href="https://github.com/charmbracelet/bubbletea/actions"><img src="https://github.com/charmbracelet/bubbletea/actions/workflows/build.yml/badge.svg" alt="Build Status"></a>
-    <a href="https://www.phorm.ai/query?projectId=a0e324b6-b706-4546-b951-6671ea60c13f"><img src="https://stuff.charm.sh/misc/phorm-badge.svg" alt="phorm.ai"></a>
 </p>
 
 The fun, functional and stateful way to build terminal apps. A Go framework
@@ -17,9 +21,8 @@ complex terminal applications, either inline, full-window, or a mix of both.
 </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
-framerate-based renderer, a renderer for high-performance scrollable
-regions which works alongside the main renderer, and mouse support.
+performance optimizations we’ve added along the way. Among those is
+a framerate-based renderer, mouse support, focus reporting and more.
 
 To get started, see the tutorial below, the [examples][examples], the
 [docs][docs], the [video tutorials][youtube] and some common [resources](#libraries-we-use-with-bubble-tea).
@@ -49,7 +52,7 @@ By the way, the non-annotated source code for this program is available
 [on GitHub][tut-source].
 
 [elm]: https://guide.elm-lang.org/architecture/
-[tut-source]: https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics
+[tut-source]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/basics
 
 ### Enough! Let's get to it.
 
@@ -61,6 +64,10 @@ import will be the Bubble Tea library, which we'll call `tea` for short.
 ```go
 package main
 
+// These imports will be used later on the tutorial. If you save the file
+// now, Go might complain they are unused, but that's fine.
+// You may also need to run `go mod tidy` to download bubbletea and its
+// dependencies.
 import (
     "fmt"
     "os"
@@ -254,8 +261,8 @@ look at the [Command Tutorial][cmd]. It's pretty simple.
 There are also several [Bubble Tea examples][examples] available and, of course,
 there are [Go Docs][docs].
 
-[cmd]: https://github.com/charmbracelet/bubbletea/tree/master/tutorials/commands/
-[examples]: https://github.com/charmbracelet/bubbletea/tree/master/examples
+[cmd]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/commands/
+[examples]: https://github.com/charmbracelet/bubbletea/tree/main/examples
 [docs]: https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc
 
 ## Debugging
@@ -310,20 +317,16 @@ your program in another window.
 - [Harmonica][harmonica]: A spring animation library for smooth, natural motion
 - [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components
 - [ntcharts][ntcharts]: A terminal charting library built for Bubble Tea and [Lip Gloss][lipgloss]
-- [Termenv][termenv]: Advanced ANSI styling for terminal applications
-- [Reflow][reflow]: Advanced ANSI-aware methods for working with text
 
 [bubbles]: https://github.com/charmbracelet/bubbles
 [lipgloss]: https://github.com/charmbracelet/lipgloss
 [harmonica]: https://github.com/charmbracelet/harmonica
 [bubblezone]: https://github.com/lrstanley/bubblezone
 [ntcharts]: https://github.com/NimbleMarkets/ntcharts
-[termenv]: https://github.com/muesli/termenv
-[reflow]: https://github.com/muesli/reflow
 
 ## Bubble Tea in the Wild
 
-There are over 8k applications built with Bubble Tea! Here are a handful of ’em.
+There are over [10,000 applications](https://github.com/charmbracelet/bubbletea/network/dependents) built with Bubble Tea! Here are a handful of ’em.
 
 ### Staff favourites
 
@@ -331,15 +334,19 @@ There are over 8k applications built with Bubble Tea! Here are a handful of ’e
 - [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal
 - [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues
 - [Tetrigo](https://github.com/Broderick-Westrope/tetrigo): Tetris in the terminal
+- [Signls](https://github.com/emprcl/signls): a generative midi sequencer designed for composition and live performance
+- [Superfile](https://github.com/yorukot/superfile): a super file manager
 
 ### In Industry
 
 - Microsoft Azure – [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform
 - Daytona – [Daytona](https://github.com/daytonaio/daytona): open source dev environment manager
+- Cockroach Labs – [CockroachDB](https://github.com/cockroachdb/cockroach): a cloud-native, high-availability distributed SQL database
 - Truffle Security Co. – [Trufflehog](https://github.com/trufflesecurity/trufflehog): find leaked credentials
-- NVIDIA – [container-canary](https://github.com/NVIDIA/container-canary) from NVIDIA: a container validator
-- AWS – [eks-node-viewer](https://github.com/awslabs/eks-node-viewer) from AWS: a tool for visualizing dynamic node usage within an EKS cluster
-- MinIO – [mc](https://github.com/minio/mc) from Min.io: the official [MinIO](https://min.io) client
+- NVIDIA – [container-canary](https://github.com/NVIDIA/container-canary): a container validator
+- AWS – [eks-node-viewer](https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an EKS cluster
+- MinIO – [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client
+- Ubuntu – [Authd](https://github.com/ubuntu/authd): an authentication daemon for cloud-based identity providers
 
 ### Charm stuff
 
@@ -382,7 +389,7 @@ of days past.
 
 ## License
 
-[MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE)
+[MIT](https://github.com/charmbracelet/bubbletea/raw/main/LICENSE)
 
 ---
 
diff --git a/Taskfile.yaml b/Taskfile.yaml
new file mode 100644
index 0000000..3507203
--- /dev/null
+++ b/Taskfile.yaml
@@ -0,0 +1,14 @@
+# https://taskfile.dev
+
+version: '3'
+
+tasks:
+  lint:
+    desc: Run lint
+    cmds:
+      - golangci-lint run
+
+  test:
+    desc: Run tests
+    cmds:
+      - go test ./... {{.CLI_ARGS}}
diff --git a/commands.go b/commands.go
index bfa3b70..ec9dac2 100644
--- a/commands.go
+++ b/commands.go
@@ -13,6 +13,27 @@ import (
 //		       return tea.Batch(someCommand, someOtherCommand)
 //	    }
 func Batch(cmds ...Cmd) Cmd {
+	return compactCmds[BatchMsg](cmds)
+}
+
+// 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.
+func Sequence(cmds ...Cmd) Cmd {
+	return compactCmds[sequenceMsg](cmds)
+}
+
+// sequenceMsg is used internally to run the given commands in order.
+type sequenceMsg []Cmd
+
+// compactCmds ignores any nil commands in cmds, and returns the most direct
+// command possible. That is, considering the non-nil commands, if there are
+// none it returns nil, if there is exactly one it returns that command
+// directly, else it returns the non-nil commands as type T.
+func compactCmds[T ~[]Cmd](cmds []Cmd) Cmd {
 	var validCmds []Cmd //nolint:prealloc
 	for _, c := range cmds {
 		if c == nil {
@@ -27,26 +48,11 @@ func Batch(cmds ...Cmd) Cmd {
 		return validCmds[0]
 	default:
 		return func() Msg {
-			return BatchMsg(validCmds)
+			return T(validCmds)
 		}
 	}
 }
 
-// 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.
-func Sequence(cmds ...Cmd) Cmd {
-	return func() Msg {
-		return sequenceMsg(cmds)
-	}
-}
-
-// 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
 // wanted to tick with the system clock every second, minute or hour you
 // could use this. It's also handy for having different things tick in sync.
diff --git a/examples/README.md b/examples/README.md
index 84e607d..f882b2c 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -30,7 +30,7 @@ and timer) together in a single application and switch between them.
 ### Credit Card Form
 
 The `credit-card-form` example demonstrates how to build a multi-step form with
-`textinput`s bubbles and validation on the inputs.
+`textinputs` bubbles and validation on the inputs.
 
 <a href="./credit-card-form/main.go">
   <img width="750" src="./credit-card-form/credit-card-form.gif" />
diff --git a/examples/chat/main.go b/examples/chat/main.go
index 4a573d5..6b03a64 100644
--- a/examples/chat/main.go
+++ b/examples/chat/main.go
@@ -14,6 +14,8 @@ import (
 	"github.com/charmbracelet/lipgloss"
 )
 
+const gap = "\n\n"
+
 func main() {
 	p := tea.NewProgram(initialModel())
 
@@ -79,6 +81,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	m.viewport, vpCmd = m.viewport.Update(msg)
 
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.viewport.Width = msg.Width
+		m.textarea.SetWidth(msg.Width)
+		m.viewport.Height = msg.Height - m.textarea.Height() - lipgloss.Height(gap)
+
+		if len(m.messages) > 0 {
+			// Wrap content before setting it.
+			m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width).Render(strings.Join(m.messages, "\n")))
+		}
+		m.viewport.GotoBottom()
 	case tea.KeyMsg:
 		switch msg.Type {
 		case tea.KeyCtrlC, tea.KeyEsc:
@@ -86,7 +98,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, tea.Quit
 		case tea.KeyEnter:
 			m.messages = append(m.messages, m.senderStyle.Render("You: ")+m.textarea.Value())
-			m.viewport.SetContent(strings.Join(m.messages, "\n"))
+			m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width).Render(strings.Join(m.messages, "\n")))
 			m.textarea.Reset()
 			m.viewport.GotoBottom()
 		}
@@ -102,8 +114,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 func (m model) View() string {
 	return fmt.Sprintf(
-		"%s\n\n%s",
+		"%s%s%s",
 		m.viewport.View(),
+		gap,
 		m.textarea.View(),
-	) + "\n\n"
+	)
 }
diff --git a/examples/glamour/main.go b/examples/glamour/main.go
index 976ee3b..1e5dd66 100644
--- a/examples/glamour/main.go
+++ b/examples/glamour/main.go
@@ -65,9 +65,20 @@ func newExample() (*example, error) {
 		BorderForeground(lipgloss.Color("62")).
 		PaddingRight(2)
 
+	// We need to adjust the width of the glamour render from our main width
+	// to account for a few things:
+	//
+	//  * The viewport border width
+	//  * The viewport padding
+	//  * The viewport margins
+	//  * The gutter glamour applies to the left side of the content
+	//
+	const glamourGutter = 2
+	glamourRenderWidth := width - vp.Style.GetHorizontalFrameSize() - glamourGutter
+
 	renderer, err := glamour.NewTermRenderer(
 		glamour.WithAutoStyle(),
-		glamour.WithWordWrap(width),
+		glamour.WithWordWrap(glamourRenderWidth),
 	)
 	if err != nil {
 		return nil, err
diff --git a/examples/go.mod b/examples/go.mod
index 67f2c9a..1905da6 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -1,15 +1,15 @@
 module examples
 
-go 1.21
+go 1.23.0
 
-toolchain go1.22.5
+toolchain go1.24.1
 
 require (
-	github.com/charmbracelet/bubbles v0.20.0
-	github.com/charmbracelet/bubbletea v1.1.0
-	github.com/charmbracelet/glamour v0.8.0
+	github.com/charmbracelet/bubbles v0.21.0
+	github.com/charmbracelet/bubbletea v1.3.4
+	github.com/charmbracelet/glamour v0.10.0
 	github.com/charmbracelet/harmonica v0.2.0
-	github.com/charmbracelet/lipgloss v0.13.1
+	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
 	github.com/charmbracelet/x/exp/teatest v0.0.0-20240521184646-23081fb03b28
 	github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776
 	github.com/lucasb-eyer/go-colorful v1.2.0
@@ -22,9 +22,12 @@ require (
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymanbagabas/go-udiff v0.2.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
-	github.com/charmbracelet/x/ansi v0.4.0 // indirect
-	github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect
-	github.com/charmbracelet/x/term v0.2.0 // indirect
+	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+	github.com/charmbracelet/x/ansi v0.9.2 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+	github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect
+	github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
+	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/dlclark/regexp2 v1.11.0 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -35,16 +38,17 @@ require (
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
-	github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
+	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/sahilm/fuzzy v0.1.1 // indirect
-	github.com/yuin/goldmark v1.7.4 // indirect
-	github.com/yuin/goldmark-emoji v1.0.3 // indirect
-	golang.org/x/net v0.27.0 // indirect
-	golang.org/x/sync v0.8.0 // indirect
-	golang.org/x/sys v0.26.0 // indirect
-	golang.org/x/term v0.22.0 // indirect
-	golang.org/x/text v0.16.0 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	github.com/yuin/goldmark v1.7.8 // indirect
+	github.com/yuin/goldmark-emoji v1.0.5 // indirect
+	golang.org/x/net v0.38.0 // indirect
+	golang.org/x/sync v0.14.0 // indirect
+	golang.org/x/sys v0.33.0 // indirect
+	golang.org/x/term v0.31.0 // indirect
+	golang.org/x/text v0.24.0 // indirect
 )
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/examples/go.sum b/examples/go.sum
index a5d1e5b..297e85d 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -14,22 +14,28 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
 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.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
-github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
-github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
-github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
+github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
 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.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A=
-github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U=
-github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU=
-github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
-github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
-github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
+github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
+github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
+github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
+github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
 github.com/charmbracelet/x/exp/teatest v0.0.0-20240521184646-23081fb03b28 h1:sOWKNRjt8uOEVgPiJVIJCse1+mUDM2F/vYY6W0Go640=
 github.com/charmbracelet/x/exp/teatest v0.0.0-20240521184646-23081fb03b28/go.mod h1:l1w+LTJZCCozeGzMEWGxRw6Mo2DfcZUvupz8HGubdes=
-github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
-github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -61,28 +67,32 @@ 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.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
-github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
-github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
-github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
-golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
-golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
+github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
-golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
-golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
diff --git a/examples/suspend/main.go b/examples/suspend/main.go
index 09dc1b8..07339bf 100644
--- a/examples/suspend/main.go
+++ b/examples/suspend/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"fmt"
 	"os"
 
@@ -23,9 +24,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 	case tea.KeyMsg:
 		switch msg.String() {
-		case "q", "ctrl+c", "esc":
+		case "q", "esc":
 			m.quitting = true
 			return m, tea.Quit
+		case "ctrl+c":
+			m.quitting = true
+			return m, tea.Interrupt
 		case "ctrl+z":
 			m.suspending = true
 			return m, tea.Suspend
@@ -39,12 +43,15 @@ func (m model) View() string {
 		return ""
 	}
 
-	return "\nPress ctrl-z to suspend, or ctrl+c to exit\n"
+	return "\nPress ctrl-z to suspend, ctrl+c to interrupt, q, or esc to exit\n"
 }
 
 func main() {
 	if _, err := tea.NewProgram(model{}).Run(); err != nil {
 		fmt.Println("Error running program:", err)
+		if errors.Is(err, tea.ErrInterrupted) {
+			os.Exit(130)
+		}
 		os.Exit(1)
 	}
 }
diff --git a/examples/table-resize/main.go b/examples/table-resize/main.go
index 762211c..01aae3f 100644
--- a/examples/table-resize/main.go
+++ b/examples/table-resize/main.go
@@ -104,7 +104,12 @@ func main() {
 				return headerStyle
 			}
 
-			if rows[row-1][1] == "Pikachu" {
+			rowIndex := row - 1
+			if rowIndex < 0 || rowIndex >= len(rows) {
+				return baseStyle
+			}
+
+			if rows[rowIndex][1] == "Pikachu" {
 				return selectedStyle
 			}
 
@@ -117,7 +122,14 @@ func main() {
 					c = dimTypeColors
 				}
 
-				color := c[fmt.Sprint(rows[row-1][col])]
+				if col >= len(rows[rowIndex]) {
+					return baseStyle
+				}
+
+				color, ok := c[rows[rowIndex][col]]
+				if !ok {
+					return baseStyle
+				}
 				return baseStyle.Foreground(color)
 			}
 
diff --git a/exec.go b/exec.go
index 7a14d2a..8048414 100644
--- a/exec.go
+++ b/exec.go
@@ -114,6 +114,7 @@ func (p *Program) exec(c ExecCommand, fn ExecCallback) {
 
 	// Execute system command.
 	if err := c.Run(); err != nil {
+		p.renderer.resetLinesRendered()
 		_ = p.RestoreTerminal() // also try to restore the terminal.
 		if fn != nil {
 			go p.Send(fn(err))
@@ -121,6 +122,9 @@ func (p *Program) exec(c ExecCommand, fn ExecCallback) {
 		return
 	}
 
+	// Maintain the existing output from the command
+	p.renderer.resetLinesRendered()
+
 	// Have the program re-capture input.
 	err := p.RestoreTerminal()
 	if fn != nil {
diff --git a/go.mod b/go.mod
index fa0e943..31fe258 100644
--- a/go.mod
+++ b/go.mod
@@ -1,25 +1,30 @@
 module github.com/charmbracelet/bubbletea
 
-go 1.18
+go 1.23.0
+
+toolchain go1.23.7
 
 require (
-	github.com/charmbracelet/lipgloss v0.13.1
-	github.com/charmbracelet/x/ansi v0.4.0
-	github.com/charmbracelet/x/term v0.2.0
+	github.com/charmbracelet/lipgloss v1.1.0
+	github.com/charmbracelet/x/ansi v0.9.3
+	github.com/charmbracelet/x/term v0.2.1
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f
 	github.com/mattn/go-localereader v0.0.1
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
 	github.com/muesli/cancelreader v0.2.2
-	golang.org/x/sync v0.8.0
-	golang.org/x/sys v0.26.0
+	golang.org/x/sync v0.16.0
+	golang.org/x/sys v0.34.0
 )
 
 require (
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
-	github.com/mattn/go-runewidth v0.0.15 // indirect
-	github.com/muesli/termenv v0.15.2 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/text v0.3.8 // indirect
 )
diff --git a/go.sum b/go.sum
index b9c3ac3..61ea117 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,15 @@
 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/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A=
-github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U=
-github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU=
-github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
-github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
-github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
+github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -14,22 +18,26 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
 github.com/mattn/go-isatty v0.0.20/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.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
-github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
-github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
-golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
 golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
diff --git a/inputreader_other.go b/inputreader_other.go
index 8e63a87..1d1b176 100644
--- a/inputreader_other.go
+++ b/inputreader_other.go
@@ -4,11 +4,16 @@
 package tea
 
 import (
+	"fmt"
 	"io"
 
 	"github.com/muesli/cancelreader"
 )
 
-func newInputReader(r io.Reader) (cancelreader.CancelReader, error) {
-	return cancelreader.NewReader(r)
+func newInputReader(r io.Reader, _ bool) (cancelreader.CancelReader, error) {
+	cr, err := cancelreader.NewReader(r)
+	if err != nil {
+		return nil, fmt.Errorf("bubbletea: error creating cancel reader: %w", err)
+	}
+	return cr, nil
 }
diff --git a/inputreader_windows.go b/inputreader_windows.go
index 449df47..1e48f70 100644
--- a/inputreader_windows.go
+++ b/inputreader_windows.go
@@ -25,7 +25,7 @@ type conInputReader struct {
 
 var _ cancelreader.CancelReader = &conInputReader{}
 
-func newInputReader(r io.Reader) (cancelreader.CancelReader, error) {
+func newInputReader(r io.Reader, enableMouse bool) (cancelreader.CancelReader, error) {
 	fallback := func(io.Reader) (cancelreader.CancelReader, error) {
 		return cancelreader.NewReader(r)
 	}
@@ -38,11 +38,21 @@ func newInputReader(r io.Reader) (cancelreader.CancelReader, error) {
 		return fallback(r)
 	}
 
-	originalMode, err := prepareConsole(conin,
-		windows.ENABLE_MOUSE_INPUT,
+	modes := []uint32{
 		windows.ENABLE_WINDOW_INPUT,
 		windows.ENABLE_EXTENDED_FLAGS,
-	)
+	}
+
+	// Since we have options to enable mouse events, [WithMouseCellMotion],
+	// [WithMouseAllMotion], and [EnableMouseCellMotion],
+	// [EnableMouseAllMotion], and [DisableMouse], we need to check if the user
+	// has enabled mouse events and add the appropriate mode accordingly.
+	// Otherwise, mouse events will be enabled all the time.
+	if enableMouse {
+		modes = append(modes, windows.ENABLE_MOUSE_INPUT)
+	}
+
+	originalMode, err := prepareConsole(conin, modes...)
 	if err != nil {
 		return nil, fmt.Errorf("failed to prepare console input: %w", err)
 	}
@@ -57,7 +67,9 @@ func newInputReader(r io.Reader) (cancelreader.CancelReader, error) {
 func (r *conInputReader) Cancel() bool {
 	r.setCanceled()
 
-	return windows.CancelIo(r.conin) == nil
+	// Warning: These cancel methods do not reliably work on console input
+	// 			and should not be counted on.
+	return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
 }
 
 // Close implements cancelreader.CancelReader.
@@ -73,8 +85,11 @@ func (r *conInputReader) Close() error {
 }
 
 // Read implements cancelreader.CancelReader.
-func (*conInputReader) Read(_ []byte) (n int, err error) {
-	return 0, nil
+func (r *conInputReader) Read(_ []byte) (n int, err error) {
+	if r.isCanceled() {
+		err = cancelreader.ErrCanceled
+	}
+	return
 }
 
 func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
@@ -93,7 +108,7 @@ func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32,
 	return originalMode, nil
 }
 
-// cancelMixin represents a goroutine-safe cancelation status.
+// cancelMixin represents a goroutine-safe cancellation status.
 type cancelMixin struct {
 	unsafeCanceled bool
 	lock           sync.Mutex
@@ -105,3 +120,10 @@ func (c *cancelMixin) setCanceled() {
 
 	c.unsafeCanceled = true
 }
+
+func (c *cancelMixin) isCanceled() bool {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	return c.unsafeCanceled
+}
diff --git a/key.go b/key.go
index ab4792a..12a161a 100644
--- a/key.go
+++ b/key.go
@@ -622,7 +622,7 @@ func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) {
 		case '<':
 			if matchIndices := mouseSGRRegex.FindSubmatchIndex(b[3:]); matchIndices != nil {
 				// SGR mouse events length is the length of the match plus the length of the escape sequence
-				mouseEventSGRLen := matchIndices[1] + 3 //nolint:gomnd
+				mouseEventSGRLen := matchIndices[1] + 3 //nolint:mnd
 				return mouseEventSGRLen, MouseMsg(parseSGRMouseEvent(b))
 			}
 		}
diff --git a/key_sequences.go b/key_sequences.go
index 15483ef..dce9bf4 100644
--- a/key_sequences.go
+++ b/key_sequences.go
@@ -119,13 +119,12 @@ func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) {
 }
 
 // detectReportFocus detects a focus report sequence.
-// nolint: gomnd
 func detectReportFocus(input []byte) (hasRF bool, width int, msg Msg) {
 	switch {
 	case bytes.Equal(input, []byte("\x1b[I")):
-		return true, 3, FocusMsg{}
+		return true, 3, FocusMsg{} //nolint:mnd
 	case bytes.Equal(input, []byte("\x1b[O")):
-		return true, 3, BlurMsg{}
+		return true, 3, BlurMsg{} //nolint:mnd
 	}
 	return false, 0, nil
 }
diff --git a/key_windows.go b/key_windows.go
index b693efd..ac98373 100644
--- a/key_windows.go
+++ b/key_windows.go
@@ -7,29 +7,29 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"time"
 
 	"github.com/erikgeiser/coninput"
 	localereader "github.com/mattn/go-localereader"
-	"golang.org/x/sys/windows"
+	"github.com/muesli/cancelreader"
 )
 
 func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
 	if coninReader, ok := input.(*conInputReader); ok {
-		return readConInputs(ctx, msgs, coninReader.conin)
+		return readConInputs(ctx, msgs, coninReader)
 	}
 
 	return readAnsiInputs(ctx, msgs, localereader.NewReader(input))
 }
 
-func readConInputs(ctx context.Context, msgsch chan<- Msg, con windows.Handle) error {
+func readConInputs(ctx context.Context, msgsch chan<- Msg, con *conInputReader) error {
 	var ps coninput.ButtonState                 // keep track of previous mouse state
 	var ws coninput.WindowBufferSizeEventRecord // keep track of the last window size event
 	for {
-		events, err := coninput.ReadNConsoleInputs(con, 16)
+		events, err := peekAndReadConsInput(con)
 		if err != nil {
-			return fmt.Errorf("read coninput events: %w", err)
+			return err
 		}
-
 		for _, event := range events {
 			var msgs []Msg
 			switch e := event.Unwrap().(type) {
@@ -84,13 +84,57 @@ func readConInputs(ctx context.Context, msgsch chan<- Msg, con windows.Handle) e
 					if err != nil {
 						return fmt.Errorf("coninput context error: %w", err)
 					}
-					return err
+					return nil
 				}
 			}
 		}
 	}
 }
 
+// Peek for new input in a tight loop and then read the input.
+// windows.CancelIo* does not work reliably so peek first and only use the data if
+// the console input is not cancelled.
+func peekAndReadConsInput(con *conInputReader) ([]coninput.InputRecord, error) {
+	events, err := peekConsInput(con)
+	if err != nil {
+		return events, err
+	}
+	events, err = coninput.ReadNConsoleInputs(con.conin, intToUint32OrDie(len(events)))
+	if con.isCanceled() {
+		return events, cancelreader.ErrCanceled
+	}
+	if err != nil {
+		return events, fmt.Errorf("read coninput events: %w", err)
+	}
+	return events, nil
+}
+
+// Convert i to unit32 or panic if it cannot be converted. Check satisifes lint G115.
+func intToUint32OrDie(i int) uint32 {
+	if i < 0 {
+		panic("cannot convert numEvents " + fmt.Sprint(i) + " to uint32")
+	}
+	return uint32(i)
+}
+
+// Keeps peeking until there is data or the input is cancelled.
+func peekConsInput(con *conInputReader) ([]coninput.InputRecord, error) {
+	for {
+		events, err := coninput.PeekNConsoleInputs(con.conin, 16)
+		if con.isCanceled() {
+			return events, cancelreader.ErrCanceled
+		}
+		if err != nil {
+			return events, fmt.Errorf("peek coninput events: %w", err)
+		}
+		if len(events) > 0 {
+			return events, nil
+		}
+		// Sleep for a bit to avoid busy waiting.
+		time.Sleep(16 * time.Millisecond)
+	}
+}
+
 func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, action MouseAction) {
 	btn := p ^ s
 	action = MouseActionPress
@@ -111,7 +155,7 @@ func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, action Mou
 		case s&coninput.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
 			button = MouseButtonForward
 		}
-		return
+		return button, action
 	}
 
 	switch {
@@ -144,7 +188,7 @@ func mouseEvent(p coninput.ButtonState, e coninput.MouseEventRecord) MouseMsg {
 		if ev.Action == MouseActionRelease {
 			ev.Type = MouseRelease
 		}
-		switch ev.Button {
+		switch ev.Button { //nolint:exhaustive
 		case MouseButtonLeft:
 			ev.Type = MouseLeft
 		case MouseButtonMiddle:
@@ -187,7 +231,7 @@ func keyType(e coninput.KeyEventRecord) KeyType {
 	shiftPressed := e.ControlKeyState.Contains(coninput.SHIFT_PRESSED)
 	ctrlPressed := e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED)
 
-	switch code {
+	switch code { //nolint:exhaustive
 	case coninput.VK_RETURN:
 		return KeyEnter
 	case coninput.VK_BACK:
@@ -273,8 +317,52 @@ func keyType(e coninput.KeyEventRecord) KeyType {
 		return KeyPgDown
 	case coninput.VK_DELETE:
 		return KeyDelete
+	case coninput.VK_F1:
+		return KeyF1
+	case coninput.VK_F2:
+		return KeyF2
+	case coninput.VK_F3:
+		return KeyF3
+	case coninput.VK_F4:
+		return KeyF4
+	case coninput.VK_F5:
+		return KeyF5
+	case coninput.VK_F6:
+		return KeyF6
+	case coninput.VK_F7:
+		return KeyF7
+	case coninput.VK_F8:
+		return KeyF8
+	case coninput.VK_F9:
+		return KeyF9
+	case coninput.VK_F10:
+		return KeyF10
+	case coninput.VK_F11:
+		return KeyF11
+	case coninput.VK_F12:
+		return KeyF12
+	case coninput.VK_F13:
+		return KeyF13
+	case coninput.VK_F14:
+		return KeyF14
+	case coninput.VK_F15:
+		return KeyF15
+	case coninput.VK_F16:
+		return KeyF16
+	case coninput.VK_F17:
+		return KeyF17
+	case coninput.VK_F18:
+		return KeyF18
+	case coninput.VK_F19:
+		return KeyF19
+	case coninput.VK_F20:
+		return KeyF20
 	default:
-		if e.ControlKeyState&(coninput.LEFT_CTRL_PRESSED|coninput.RIGHT_CTRL_PRESSED) == 0 {
+		switch {
+		case e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED) && e.ControlKeyState.Contains(coninput.RIGHT_ALT_PRESSED):
+			// AltGr is pressed, then it's a rune.
+			fallthrough
+		case !e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED) && !e.ControlKeyState.Contains(coninput.RIGHT_CTRL_PRESSED):
 			return KeyRunes
 		}
 
@@ -334,16 +422,18 @@ func keyType(e coninput.KeyEventRecord) KeyType {
 		case '\x1a':
 			return KeyCtrlZ
 		case '\x1b':
-			return KeyCtrlCloseBracket
+			return KeyCtrlOpenBracket // KeyEscape
 		case '\x1c':
 			return KeyCtrlBackslash
 		case '\x1f':
 			return KeyCtrlUnderscore
 		}
 
-		switch code {
+		switch code { //nolint:exhaustive
 		case coninput.VK_OEM_4:
 			return KeyCtrlOpenBracket
+		case coninput.VK_OEM_6:
+			return KeyCtrlCloseBracket
 		}
 
 		return KeyRunes
diff --git a/logging.go b/logging.go
index a531181..349758c 100644
--- a/logging.go
+++ b/logging.go
@@ -33,7 +33,7 @@ type LogOptionsSetter interface {
 
 // 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, 0o600) //nolint:gomnd
+	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd
 	if err != nil {
 		return nil, fmt.Errorf("error opening file for logging: %w", err)
 	}
diff --git a/mouse.go b/mouse.go
index 6ec51cc..490f49a 100644
--- a/mouse.go
+++ b/mouse.go
@@ -172,7 +172,7 @@ const (
 func parseSGRMouseEvent(buf []byte) MouseEvent {
 	str := string(buf[3:])
 	matches := mouseSGRRegex.FindStringSubmatch(str)
-	if len(matches) != 5 { //nolint:gomnd
+	if len(matches) != 5 { //nolint:mnd
 		// Unreachable, we already checked the regex in `detectOneMsg`.
 		panic("invalid mouse event")
 	}
diff --git a/nil_renderer.go b/nil_renderer.go
index 0bc4a17..1bc909b 100644
--- a/nil_renderer.go
+++ b/nil_renderer.go
@@ -26,3 +26,4 @@ func (n nilRenderer) setWindowTitle(_ string)    {}
 func (n nilRenderer) reportFocus() bool          { return false }
 func (n nilRenderer) enableReportFocus()         {}
 func (n nilRenderer) disableReportFocus()        {}
+func (n nilRenderer) resetLinesRendered()        {}
diff --git a/options.go b/options.go
index 12e92e4..49cf378 100644
--- a/options.go
+++ b/options.go
@@ -19,7 +19,7 @@ type ProgramOption func(*Program)
 // cancelled it will exit with an error ErrProgramKilled.
 func WithContext(ctx context.Context) ProgramOption {
 	return func(p *Program) {
-		p.ctx = ctx
+		p.externalCtx = ctx
 	}
 }
 
@@ -186,7 +186,7 @@ func WithoutRenderer() ProgramOption {
 // This feature is provisional, and may be changed or removed in a future version
 // of this package.
 //
-// Deprecated: this incurs a noticable performance hit. A future release will
+// Deprecated: this incurs a noticeable performance hit. A future release will
 // optimize ANSI automatically without the performance penalty.
 func WithANSICompressor() ProgramOption {
 	return func(p *Program) {
diff --git a/renderer.go b/renderer.go
index 9eb7943..5b6df66 100644
--- a/renderer.go
+++ b/renderer.go
@@ -79,6 +79,9 @@ type renderer interface {
 
 	// disableReportFocus stops reporting focus events to the program.
 	disableReportFocus()
+
+	// resetLinesRendered ensures exec output remains on screen on exit
+	resetLinesRendered()
 }
 
 // repaintMsg forces a full repaint.
diff --git a/screen.go b/screen.go
index dfec48f..02e1bfb 100644
--- a/screen.go
+++ b/screen.go
@@ -131,7 +131,7 @@ func EnableBracketedPaste() Msg {
 type enableBracketedPasteMsg struct{}
 
 // DisableBracketedPaste is a special command that tells the Bubble Tea program
-// to accept bracketed paste input.
+// to stop processing bracketed paste input.
 //
 // Note that bracketed paste will be automatically disabled when the
 // program quits.
diff --git a/standard_renderer.go b/standard_renderer.go
index 0cb0ef3..d6dce6d 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -34,7 +34,9 @@ type standardRenderer struct {
 	ticker             *time.Ticker
 	done               chan struct{}
 	lastRender         string
+	lastRenderedLines  []string
 	linesRendered      int
+	altLinesRendered   int
 	useANSICompressor  bool
 	once               sync.Once
 
@@ -161,13 +163,20 @@ func (r *standardRenderer) flush() {
 	defer r.mtx.Unlock()
 
 	if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
-		// Nothing to do
+		// Nothing to do.
 		return
 	}
 
-	// Output buffer
+	// Output buffer.
 	buf := &bytes.Buffer{}
 
+	// Moving to the beginning of the section, that we rendered.
+	if r.altScreenActive {
+		buf.WriteString(ansi.CursorHomePosition)
+	} else if r.linesRendered > 1 {
+		buf.WriteString(ansi.CursorUp(r.linesRendered - 1))
+	}
+
 	newLines := strings.Split(r.buf.String(), "\n")
 
 	// If we know the output's height, we can use it to determine how many
@@ -178,97 +187,87 @@ func (r *standardRenderer) flush() {
 		newLines = newLines[len(newLines)-r.height:]
 	}
 
-	numLinesThisFlush := len(newLines)
-	oldLines := strings.Split(r.lastRender, "\n")
-	skipLines := make(map[int]struct{})
 	flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive
 
-	// Clear any lines we painted in the last render.
-	if r.linesRendered > 0 {
-		for i := r.linesRendered - 1; i > 0; i-- {
-			// if we are clearing queued messages, we want to clear all lines, since
-			// printing messages allows for native terminal word-wrap, we
-			// don't have control over the queued lines
-			if flushQueuedMessages {
-				buf.WriteString(ansi.EraseEntireLine)
-			} else if (len(newLines) <= len(oldLines)) && (len(newLines) > i && len(oldLines) > i) && (newLines[i] == oldLines[i]) {
-				// If the number of lines we want to render hasn't increased and
-				// new line is the same as the old line we can skip rendering for
-				// this line as a performance optimization.
-				skipLines[i] = struct{}{}
-			} else if _, exists := r.ignoreLines[i]; !exists {
-				buf.WriteString(ansi.EraseEntireLine)
-			}
-
-			buf.WriteString(ansi.CursorUp1)
-		}
-
-		if _, exists := r.ignoreLines[0]; !exists {
-			// We need to return to the start of the line here to properly
-			// erase it. Going back the entire width of the terminal will
-			// usually be farther than we need to go, but terminal emulators
-			// will stop the cursor at the start of the line as a rule.
-			//
-			// We use this sequence in particular because it's part of the ANSI
-			// standard (whereas others are proprietary to, say, VT100/VT52).
-			// If cursor previous line (ESC[ + <n> + F) were better supported
-			// we could use that above to eliminate this step.
-			buf.WriteString(ansi.CursorLeft(r.width))
-			buf.WriteString(ansi.EraseEntireLine)
-		}
-	}
-
-	// Merge the set of lines we're skipping as a rendering optimization with
-	// the set of lines we've explicitly asked the renderer to ignore.
-	for k, v := range r.ignoreLines {
-		skipLines[k] = v
-	}
-
 	if flushQueuedMessages {
-		// Dump the lines we've queued up for printing
+		// Dump the lines we've queued up for printing.
 		for _, line := range r.queuedMessageLines {
+			if ansi.StringWidth(line) < r.width {
+				// We only erase the rest of the line when the line is shorter than
+				// the width of the terminal. When the cursor reaches the end of
+				// the line, any escape sequences that follow will only affect the
+				// last cell of the line.
+
+				// Removing previously rendered content at the end of line.
+				line = line + ansi.EraseLineRight
+			}
+
 			_, _ = buf.WriteString(line)
 			_, _ = buf.WriteString("\r\n")
 		}
-		// clear the queued message lines
+		// Clear the queued message lines.
 		r.queuedMessageLines = []string{}
 	}
 
-	// Paint new lines
+	// Paint new lines.
 	for i := 0; i < len(newLines); i++ {
-		if _, skip := skipLines[i]; skip {
+		canSkip := !flushQueuedMessages && // Queuing messages triggers repaint -> we don't have access to previous frame content.
+			len(r.lastRenderedLines) > i && r.lastRenderedLines[i] == newLines[i] // Previously rendered line is the same.
+
+		if _, ignore := r.ignoreLines[i]; ignore || canSkip {
 			// Unless this is the last line, move the cursor down.
 			if i < len(newLines)-1 {
-				buf.WriteString(ansi.CursorDown1)
-			}
-		} else {
-			if i == 0 && r.lastRender == "" {
-				// On first render, reset the cursor to the start of the line
-				// before writing anything.
-				buf.WriteByte('\r')
+				buf.WriteByte('\n')
 			}
+			continue
+		}
 
-			line := newLines[i]
-
-			// Truncate lines wider than the width of the window to avoid
-			// wrapping, which will mess up rendering. If we don't have the
-			// width of the window this will be ignored.
-			//
-			// Note that on Windows we only get the width of the window on
-			// program initialization, so after a resize this won't perform
-			// correctly (signal SIGWINCH is not supported on Windows).
-			if r.width > 0 {
-				line = ansi.Truncate(line, r.width, "")
-			}
+		if i == 0 && r.lastRender == "" {
+			// On first render, reset the cursor to the start of the line
+			// before writing anything.
+			buf.WriteByte('\r')
+		}
 
-			_, _ = buf.WriteString(line)
+		line := newLines[i]
+
+		// Truncate lines wider than the width of the window to avoid
+		// wrapping, which will mess up rendering. If we don't have the
+		// width of the window this will be ignored.
+		//
+		// Note that on Windows we only get the width of the window on
+		// program initialization, so after a resize this won't perform
+		// correctly (signal SIGWINCH is not supported on Windows).
+		if r.width > 0 {
+			line = ansi.Truncate(line, r.width, "")
+		}
 
-			if i < len(newLines)-1 {
-				_, _ = buf.WriteString("\r\n")
-			}
+		if ansi.StringWidth(line) < r.width {
+			// We only erase the rest of the line when the line is shorter than
+			// the width of the terminal. When the cursor reaches the end of
+			// the line, any escape sequences that follow will only affect the
+			// last cell of the line.
+
+			// Removing previously rendered content at the end of line.
+			line = line + ansi.EraseLineRight
 		}
+
+		_, _ = buf.WriteString(line)
+
+		if i < len(newLines)-1 {
+			_, _ = buf.WriteString("\r\n")
+		}
+	}
+
+	// Clearing left over content from last render.
+	if r.lastLinesRendered() > len(newLines) {
+		buf.WriteString(ansi.EraseScreenBelow)
+	}
+
+	if r.altScreenActive {
+		r.altLinesRendered = len(newLines)
+	} else {
+		r.linesRendered = len(newLines)
 	}
-	r.linesRendered = numLinesThisFlush
 
 	// Make sure the cursor is at the start of the last line to keep rendering
 	// behavior consistent.
@@ -276,16 +275,29 @@ func (r *standardRenderer) flush() {
 		// This case fixes a bug in macOS terminal. In other terminals the
 		// other case seems to do the job regardless of whether or not we're
 		// using the full terminal window.
-		buf.WriteString(ansi.SetCursorPosition(0, r.linesRendered))
+		buf.WriteString(ansi.CursorPosition(0, len(newLines)))
 	} else {
-		buf.WriteString(ansi.CursorLeft(r.width))
+		buf.WriteString(ansi.CursorBackward(r.width))
 	}
 
 	_, _ = r.out.Write(buf.Bytes())
 	r.lastRender = r.buf.String()
+
+	// Save previously rendered lines for comparison in the next render. If we
+	// don't do this, we can't skip rendering lines that haven't changed.
+	// See https://github.com/charmbracelet/bubbletea/pull/1233
+	r.lastRenderedLines = newLines
 	r.buf.Reset()
 }
 
+// lastLinesRendered returns the number of lines rendered lastly.
+func (r *standardRenderer) lastLinesRendered() int {
+	if r.altScreenActive {
+		return r.altLinesRendered
+	}
+	return r.linesRendered
+}
+
 // write writes to the internal buffer. The buffer will be outputted via the
 // ticker which calls flush().
 func (r *standardRenderer) write(s string) {
@@ -306,6 +318,7 @@ func (r *standardRenderer) write(s string) {
 
 func (r *standardRenderer) repaint() {
 	r.lastRender = ""
+	r.lastRenderedLines = nil
 }
 
 func (r *standardRenderer) clearScreen() {
@@ -313,7 +326,7 @@ func (r *standardRenderer) clearScreen() {
 	defer r.mtx.Unlock()
 
 	r.execute(ansi.EraseEntireScreen)
-	r.execute(ansi.CursorOrigin)
+	r.execute(ansi.CursorHomePosition)
 
 	r.repaint()
 }
@@ -334,7 +347,7 @@ func (r *standardRenderer) enterAltScreen() {
 	}
 
 	r.altScreenActive = true
-	r.execute(ansi.EnableAltScreenBuffer)
+	r.execute(ansi.SetAltScreenSaveCursorMode)
 
 	// Ensure that the terminal is cleared, even when it doesn't support
 	// alt screen (or alt screen support is disabled, like GNU screen by
@@ -343,7 +356,7 @@ func (r *standardRenderer) enterAltScreen() {
 	// Note: we can't use r.clearScreen() here because the mutex is already
 	// locked.
 	r.execute(ansi.EraseEntireScreen)
-	r.execute(ansi.CursorOrigin)
+	r.execute(ansi.CursorHomePosition)
 
 	// cmd.exe and other terminals keep separate cursor states for the AltScreen
 	// and the main buffer. We have to explicitly reset the cursor visibility
@@ -354,6 +367,9 @@ func (r *standardRenderer) enterAltScreen() {
 		r.execute(ansi.ShowCursor)
 	}
 
+	// Entering the alt screen resets the lines rendered count.
+	r.altLinesRendered = 0
+
 	r.repaint()
 }
 
@@ -366,7 +382,7 @@ func (r *standardRenderer) exitAltScreen() {
 	}
 
 	r.altScreenActive = false
-	r.execute(ansi.DisableAltScreenBuffer)
+	r.execute(ansi.ResetAltScreenSaveCursorMode)
 
 	// cmd.exe and other terminals keep separate cursor states for the AltScreen
 	// and the main buffer. We have to explicitly reset the cursor visibility
@@ -400,49 +416,49 @@ func (r *standardRenderer) enableMouseCellMotion() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.EnableMouseCellMotion)
+	r.execute(ansi.SetButtonEventMouseMode)
 }
 
 func (r *standardRenderer) disableMouseCellMotion() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.DisableMouseCellMotion)
+	r.execute(ansi.ResetButtonEventMouseMode)
 }
 
 func (r *standardRenderer) enableMouseAllMotion() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.EnableMouseAllMotion)
+	r.execute(ansi.SetAnyEventMouseMode)
 }
 
 func (r *standardRenderer) disableMouseAllMotion() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.DisableMouseAllMotion)
+	r.execute(ansi.ResetAnyEventMouseMode)
 }
 
 func (r *standardRenderer) enableMouseSGRMode() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.EnableMouseSgrExt)
+	r.execute(ansi.SetSgrExtMouseMode)
 }
 
 func (r *standardRenderer) disableMouseSGRMode() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.DisableMouseSgrExt)
+	r.execute(ansi.ResetSgrExtMouseMode)
 }
 
 func (r *standardRenderer) enableBracketedPaste() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.EnableBracketedPaste)
+	r.execute(ansi.SetBracketedPasteMode)
 	r.bpActive = true
 }
 
@@ -450,7 +466,7 @@ func (r *standardRenderer) disableBracketedPaste() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.DisableBracketedPaste)
+	r.execute(ansi.ResetBracketedPasteMode)
 	r.bpActive = false
 }
 
@@ -465,7 +481,7 @@ func (r *standardRenderer) enableReportFocus() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.EnableReportFocus)
+	r.execute(ansi.SetFocusEventMode)
 	r.reportingFocus = true
 }
 
@@ -473,7 +489,7 @@ func (r *standardRenderer) disableReportFocus() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	r.execute(ansi.DisableReportFocus)
+	r.execute(ansi.ResetFocusEventMode)
 	r.reportingFocus = false
 }
 
@@ -494,7 +510,7 @@ func (r *standardRenderer) setWindowTitle(title string) {
 func (r *standardRenderer) setIgnoredLines(from int, to int) {
 	// Lock if we're going to be clearing some lines since we don't want
 	// anything jacking our cursor.
-	if r.linesRendered > 0 {
+	if r.lastLinesRendered() > 0 {
 		r.mtx.Lock()
 		defer r.mtx.Unlock()
 	}
@@ -507,16 +523,17 @@ func (r *standardRenderer) setIgnoredLines(from int, to int) {
 	}
 
 	// Erase ignored lines
-	if r.linesRendered > 0 {
+	lastLinesRendered := r.lastLinesRendered()
+	if lastLinesRendered > 0 {
 		buf := &bytes.Buffer{}
 
-		for i := r.linesRendered - 1; i >= 0; i-- {
+		for i := lastLinesRendered - 1; i >= 0; i-- {
 			if _, exists := r.ignoreLines[i]; exists {
 				buf.WriteString(ansi.EraseEntireLine)
 			}
-			buf.WriteString(ansi.CursorUp1)
+			buf.WriteString(ansi.CUU1)
 		}
-		buf.WriteString(ansi.SetCursorPosition(0, r.linesRendered)) // put cursor back
+		buf.WriteString(ansi.CursorPosition(0, lastLinesRendered)) // put cursor back
 		_, _ = r.out.Write(buf.Bytes())
 	}
 }
@@ -528,6 +545,10 @@ func (r *standardRenderer) clearIgnoredLines() {
 	r.ignoreLines = nil
 }
 
+func (r *standardRenderer) resetLinesRendered() {
+	r.linesRendered = 0
+}
+
 // insertTop effectively scrolls up. It inserts lines at the top of a given
 // area designated to be a scrollable region, pushing everything else down.
 // This is roughly how ncurses does it.
@@ -555,14 +576,14 @@ func (r *standardRenderer) insertTop(lines []string, topBoundary, bottomBoundary
 
 	buf := &bytes.Buffer{}
 
-	buf.WriteString(ansi.SetScrollingRegion(topBoundary, bottomBoundary))
-	buf.WriteString(ansi.SetCursorPosition(0, topBoundary))
+	buf.WriteString(ansi.SetTopBottomMargins(topBoundary, bottomBoundary))
+	buf.WriteString(ansi.CursorPosition(0, topBoundary))
 	buf.WriteString(ansi.InsertLine(len(lines)))
 	_, _ = buf.WriteString(strings.Join(lines, "\r\n"))
-	buf.WriteString(ansi.SetScrollingRegion(0, r.height))
+	buf.WriteString(ansi.SetTopBottomMargins(0, r.height))
 
 	// Move cursor back to where the main rendering routine expects it to be
-	buf.WriteString(ansi.SetCursorPosition(0, r.linesRendered))
+	buf.WriteString(ansi.CursorPosition(0, r.lastLinesRendered()))
 
 	_, _ = r.out.Write(buf.Bytes())
 }
@@ -585,13 +606,13 @@ func (r *standardRenderer) insertBottom(lines []string, topBoundary, bottomBound
 
 	buf := &bytes.Buffer{}
 
-	buf.WriteString(ansi.SetScrollingRegion(topBoundary, bottomBoundary))
-	buf.WriteString(ansi.SetCursorPosition(0, bottomBoundary))
+	buf.WriteString(ansi.SetTopBottomMargins(topBoundary, bottomBoundary))
+	buf.WriteString(ansi.CursorPosition(0, bottomBoundary))
 	_, _ = buf.WriteString("\r\n" + strings.Join(lines, "\r\n"))
-	buf.WriteString(ansi.SetScrollingRegion(0, r.height))
+	buf.WriteString(ansi.SetTopBottomMargins(0, r.height))
 
 	// Move cursor back to where the main rendering routine expects it to be
-	buf.WriteString(ansi.SetCursorPosition(0, r.linesRendered))
+	buf.WriteString(ansi.CursorPosition(0, r.lastLinesRendered()))
 
 	_, _ = r.out.Write(buf.Bytes())
 }
diff --git a/tea.go b/tea.go
index 87211ba..f7882b3 100644
--- a/tea.go
+++ b/tea.go
@@ -16,6 +16,7 @@ import (
 	"io"
 	"os"
 	"os/signal"
+	"runtime"
 	"runtime/debug"
 	"sync"
 	"sync/atomic"
@@ -26,9 +27,16 @@ import (
 	"golang.org/x/sync/errgroup"
 )
 
-// ErrProgramKilled is returned by [Program.Run] when the program got killed.
+// ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic.
+var ErrProgramPanic = errors.New("program experienced a panic")
+
+// ErrProgramKilled is returned by [Program.Run] when the program gets killed.
 var ErrProgramKilled = errors.New("program was killed")
 
+// ErrInterrupted is returned by [Program.Run] when the program get a SIGINT
+// signal, or when it receives a [InterruptMsg].
+var ErrInterrupted = errors.New("program was interrupted")
+
 // Msg contain data from the result of a IO operation. Msgs trigger the update
 // function and, henceforth, the UI.
 type Msg interface{}
@@ -65,7 +73,7 @@ const (
 	customInput
 )
 
-// String implements the stringer interface for [inputType]. It is inteded to
+// String implements the stringer interface for [inputType]. It is intended to
 // be used in testing.
 func (i inputType) String() string {
 	return [...]string{
@@ -142,6 +150,12 @@ type Program struct {
 
 	inputType inputType
 
+	// externalCtx is a context that was passed in via WithContext, otherwise defaulting
+	// to ctx.Background() (in case it was not), the internal context is derived from it.
+	externalCtx context.Context
+
+	// ctx is the programs's internal context for signalling internal teardown.
+	// It is built and derived from the externalCtx in NewProgram().
 	ctx    context.Context
 	cancel context.CancelFunc
 
@@ -179,6 +193,9 @@ type Program struct {
 	// fps is the frames per second we should set on the renderer, if
 	// applicable,
 	fps int
+
+	// mouseMode is true if the program should enable mouse mode on Windows.
+	mouseMode bool
 }
 
 // Quit is a special command that tells the Bubble Tea program to exit.
@@ -186,8 +203,8 @@ func Quit() Msg {
 	return QuitMsg{}
 }
 
-// QuitMsg signals that the program should quit. You can send a QuitMsg with
-// Quit.
+// QuitMsg signals that the program should quit. You can send a [QuitMsg] with
+// [Quit].
 type QuitMsg struct{}
 
 // Suspend is a special command that tells the Bubble Tea program to suspend.
@@ -199,13 +216,28 @@ func Suspend() Msg {
 // This usually happens when ctrl+z is pressed on common programs, but since
 // bubbletea puts the terminal in raw mode, we need to handle it in a
 // per-program basis.
-// You can send this message with Suspend.
+//
+// You can send this message with [Suspend()].
 type SuspendMsg struct{}
 
-// ResumeMsg can be listen to to do something once a program is resumed back
+// ResumeMsg can be listen to do something once a program is resumed back
 // from a suspend state.
 type ResumeMsg struct{}
 
+// InterruptMsg signals the program should suspend.
+// This usually happens when ctrl+c is pressed on common programs, but since
+// bubbletea puts the terminal in raw mode, we need to handle it in a
+// per-program basis.
+//
+// You can send this message with [Interrupt()].
+type InterruptMsg struct{}
+
+// Interrupt is a special command that tells the Bubble Tea program to
+// interrupt.
+func Interrupt() Msg {
+	return InterruptMsg{}
+}
+
 // NewProgram creates a new Program.
 func NewProgram(model Model, opts ...ProgramOption) *Program {
 	p := &Program{
@@ -220,11 +252,11 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 
 	// 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()
+	if p.externalCtx == nil {
+		p.externalCtx = context.Background()
 	}
 	// Initialize context and teardown channel.
-	p.ctx, p.cancel = context.WithCancel(p.ctx)
+	p.ctx, p.cancel = context.WithCancel(p.externalCtx)
 
 	// if no output was set, set it to stdout
 	if p.output == nil {
@@ -263,9 +295,14 @@ func (p *Program) handleSignals() chan struct{} {
 			case <-p.ctx.Done():
 				return
 
-			case <-sig:
+			case s := <-sig:
 				if atomic.LoadUint32(&p.ignoreSignals) == 0 {
-					p.msgs <- QuitMsg{}
+					switch s {
+					case syscall.SIGINT:
+						p.msgs <- InterruptMsg{}
+					default:
+						p.msgs <- QuitMsg{}
+					}
 					return
 				}
 			}
@@ -318,7 +355,11 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
 				go func() {
 					// Recover from panics.
 					if !p.startupOptions.has(withoutCatchPanics) {
-						defer p.recoverFromPanic()
+						defer func() {
+							if r := recover(); r != nil {
+								p.recoverFromGoPanic(r)
+							}
+						}()
 					}
 
 					msg := cmd() // this can be long.
@@ -362,6 +403,9 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 			case QuitMsg:
 				return model, nil
 
+			case InterruptMsg:
+				return model, ErrInterrupted
+
 			case SuspendMsg:
 				if suspendSupported {
 					p.suspend()
@@ -386,9 +430,25 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				// mouse mode (1006) is a no-op if the terminal doesn't support it.
 				p.renderer.enableMouseSGRMode()
 
+				// XXX: This is used to enable mouse mode on Windows. We need
+				// to reinitialize the cancel reader to get the mouse events to
+				// work.
+				if runtime.GOOS == "windows" && !p.mouseMode {
+					p.mouseMode = true
+					p.initCancelReader(true) //nolint:errcheck,gosec
+				}
+
 			case disableMouseMsg:
 				p.disableMouse()
 
+				// XXX: On Windows, mouse mode is enabled on the input reader
+				// level. We need to instruct the input reader to stop reading
+				// mouse events.
+				if runtime.GOOS == "windows" && p.mouseMode {
+					p.mouseMode = false
+					p.initCancelReader(true) //nolint:errcheck,gosec
+				}
+
 			case showCursorMsg:
 				p.renderer.showCursor()
 
@@ -413,7 +473,11 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 
 			case BatchMsg:
 				for _, cmd := range msg {
-					cmds <- cmd
+					select {
+					case <-p.ctx.Done():
+						return model, nil
+					case cmds <- cmd:
+					}
 				}
 				continue
 
@@ -436,7 +500,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 								})
 							}
 
-							//nolint:errcheck
+							//nolint:errcheck,gosec
 							g.Wait() // wait for all commands from batch msg to finish
 							continue
 						}
@@ -459,7 +523,13 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 
 			var cmd Cmd
 			model, cmd = model.Update(msg) // run update
-			cmds <- cmd                    // process command (if any)
+
+			select {
+			case <-p.ctx.Done():
+				return model, nil
+			case cmds <- cmd: // process command (if any)
+			}
+
 			p.renderer.write(model.View()) // send view to renderer
 		}
 	}
@@ -468,11 +538,15 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 // Run initializes the program and runs its event loops, blocking until it gets
 // terminated by either [Program.Quit], [Program.Kill], or its signal handler.
 // Returns the final model.
-func (p *Program) Run() (Model, error) {
+func (p *Program) Run() (returnModel Model, returnErr error) {
 	p.handlers = channelHandlers{}
 	cmds := make(chan Cmd)
-	p.errs = make(chan error)
-	p.finished = make(chan struct{}, 1)
+	p.errs = make(chan error, 1)
+
+	p.finished = make(chan struct{})
+	defer func() {
+		close(p.finished)
+	}()
 
 	defer p.cancel()
 
@@ -521,7 +595,12 @@ func (p *Program) Run() (Model, error) {
 
 	// Recover from panics.
 	if !p.startupOptions.has(withoutCatchPanics) {
-		defer p.recoverFromPanic()
+		defer func() {
+			if r := recover(); r != nil {
+				returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic)
+				p.recoverFromPanic(r)
+			}
+		}()
 	}
 
 	// If no renderer is set use the standard one.
@@ -552,6 +631,11 @@ func (p *Program) Run() (Model, error) {
 		p.renderer.enableMouseAllMotion()
 		p.renderer.enableMouseSGRMode()
 	}
+
+	// XXX: Should we enable mouse mode on Windows?
+	// This needs to happen before initializing the cancel and input reader.
+	p.mouseMode = p.startupOptions&withMouseCellMotion != 0 || p.startupOptions&withMouseAllMotion != 0
+
 	if p.startupOptions&withReportFocus != 0 {
 		p.renderer.enableReportFocus()
 	}
@@ -580,7 +664,7 @@ func (p *Program) Run() (Model, error) {
 
 	// Subscribe to user input.
 	if p.input != nil {
-		if err := p.initCancelReader(); err != nil {
+		if err := p.initCancelReader(false); err != nil {
 			return model, err
 		}
 	}
@@ -593,10 +677,27 @@ func (p *Program) Run() (Model, error) {
 
 	// Run event loop, handle updates and draw.
 	model, err := p.eventLoop(model, cmds)
-	killed := p.ctx.Err() != nil
+
+	if err == nil && len(p.errs) > 0 {
+		err = <-p.errs // Drain a leftover error in case eventLoop crashed
+	}
+
+	killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil
 	if killed {
-		err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err())
+		if err == nil && p.externalCtx.Err() != nil {
+			// Return also as context error the cancellation of an external context.
+			// This is the context the user knows about and should be able to act on.
+			err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err())
+		} else if err == nil && p.ctx.Err() != nil {
+			// Return only that the program was killed (not the internal mechanism).
+			// The user does not know or need to care about the internal program context.
+			err = ErrProgramKilled
+		} else {
+			// Return that the program was killed and also the error that caused it.
+			err = fmt.Errorf("%w: %w", ErrProgramKilled, err)
+		}
 	} else {
+		// Graceful shutdown of the program (not killed):
 		// Ensure we rendered the final state of the model.
 		p.renderer.write(model.View())
 	}
@@ -651,11 +752,11 @@ func (p *Program) Quit() {
 	p.Send(Quit())
 }
 
-// Kill stops the program immediately and restores the former terminal state.
+// Kill signals the program to stop immediately and restore the former terminal state.
 // The final render that you would normally see when quitting will be skipped.
 // [program.Run] returns a [ErrProgramKilled] error.
 func (p *Program) Kill() {
-	p.shutdown(true)
+	p.cancel()
 }
 
 // Wait waits/blocks until the underlying Program finished shutting down.
@@ -664,7 +765,11 @@ func (p *Program) Wait() {
 }
 
 // shutdown performs operations to free up resources and restore the terminal
-// to its original state.
+// to its original state. It is called once at the end of the program's lifetime.
+//
+// This method should not be called to signal the program to be killed/shutdown.
+// Doing so can lead to race conditions with the eventual call at the program's end.
+// As alternatives, the [Quit] or [Kill] convenience methods should be used instead.
 func (p *Program) shutdown(kill bool) {
 	p.cancel()
 
@@ -691,19 +796,30 @@ func (p *Program) shutdown(kill bool) {
 	}
 
 	_ = p.restoreTerminalState()
-	if !kill {
-		p.finished <- struct{}{}
-	}
 }
 
 // recoverFromPanic recovers from a panic, prints the stack trace, and restores
 // the terminal to a usable state.
-func (p *Program) recoverFromPanic() {
-	if r := recover(); r != nil {
-		p.shutdown(true)
-		fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
-		debug.PrintStack()
+func (p *Program) recoverFromPanic(r interface{}) {
+	select {
+	case p.errs <- ErrProgramPanic:
+	default:
 	}
+	p.shutdown(true) // Ok to call here, p.Run() cannot do it anymore.
+	fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
+	debug.PrintStack()
+}
+
+// recoverFromGoPanic recovers from a goroutine panic, prints a stack trace and
+// signals for the program to be killed and terminal restored to a usable state.
+func (p *Program) recoverFromGoPanic(r interface{}) {
+	select {
+	case p.errs <- ErrProgramPanic:
+	default:
+	}
+	p.cancel()
+	fmt.Printf("Caught goroutine panic:\n\n%s\n\nRestoring terminal...\n\n", r)
+	debug.PrintStack()
 }
 
 // ReleaseTerminal restores the original terminal state and cancels the input
@@ -735,7 +851,7 @@ func (p *Program) RestoreTerminal() error {
 	if err := p.initTerminal(); err != nil {
 		return err
 	}
-	if err := p.initCancelReader(); err != nil {
+	if err := p.initCancelReader(false); err != nil {
 		return err
 	}
 	if p.altScreenWasActive {
diff --git a/tty.go b/tty.go
index 0250778..6812bfc 100644
--- a/tty.go
+++ b/tty.go
@@ -52,7 +52,7 @@ func (p *Program) restoreTerminalState() error {
 			p.renderer.exitAltScreen()
 
 			// give the terminal a moment to catch up
-			time.Sleep(time.Millisecond * 10) //nolint:gomnd
+			time.Sleep(time.Millisecond * 10) //nolint:mnd
 		}
 	}
 
@@ -75,9 +75,14 @@ func (p *Program) restoreInput() error {
 }
 
 // initCancelReader (re)commences reading inputs.
-func (p *Program) initCancelReader() error {
+func (p *Program) initCancelReader(cancel bool) error {
+	if cancel && p.cancelReader != nil {
+		p.cancelReader.Cancel()
+		p.waitForReadLoop()
+	}
+
 	var err error
-	p.cancelReader, err = newInputReader(p.input)
+	p.cancelReader, err = newInputReader(p.input, p.mouseMode)
 	if err != nil {
 		return fmt.Errorf("error creating cancelreader: %w", err)
 	}
@@ -104,7 +109,7 @@ func (p *Program) readLoop() {
 func (p *Program) waitForReadLoop() {
 	select {
 	case <-p.readLoopDone:
-	case <-time.After(500 * time.Millisecond): //nolint:gomnd
+	case <-time.After(500 * time.Millisecond): //nolint:mnd
 		// The read loop hangs, which means the input
 		// cancelReader's cancel function has returned true even
 		// though it was not able to cancel the read.
diff --git a/tty_windows.go b/tty_windows.go
index a3a2525..154491a 100644
--- a/tty_windows.go
+++ b/tty_windows.go
@@ -19,7 +19,7 @@ func (p *Program) initInput() (err error) {
 		p.ttyInput = f
 		p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd())
 		if err != nil {
-			return err
+			return fmt.Errorf("error making raw: %w", err)
 		}
 
 		// Enable VT input
@@ -38,7 +38,7 @@ func (p *Program) initInput() (err error) {
 		p.ttyOutput = f
 		p.previousOutputState, err = term.GetState(f.Fd())
 		if err != nil {
-			return err
+			return fmt.Errorf("error getting state: %w", err)
 		}
 
 		var mode uint32
@@ -51,14 +51,14 @@ func (p *Program) initInput() (err error) {
 		}
 	}
 
-	return
+	return nil
 }
 
 // Open the Windows equivalent of a TTY.
 func openInputTTY() (*os.File, error) {
 	f, err := os.OpenFile("CONIN$", os.O_RDWR, 0o644)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error opening file: %w", err)
 	}
 	return f, nil
 }



Test patch:
diff --git a/commands_test.go b/commands_test.go
index feb1ee8..0306929 100644
--- a/commands_test.go
+++ b/commands_test.go
@@ -82,25 +82,33 @@ func TestSequentially(t *testing.T) {
 }
 
 func TestBatch(t *testing.T) {
+	testMultipleCommands[BatchMsg](t, Batch)
+}
+
+func TestSequence(t *testing.T) {
+	testMultipleCommands[sequenceMsg](t, Sequence)
+}
+
+func testMultipleCommands[T ~[]Cmd](t *testing.T, createFn func(cmd ...Cmd) Cmd) {
 	t.Run("nil cmd", func(t *testing.T) {
-		if b := Batch(nil); b != nil {
+		if b := createFn(nil); b != nil {
 			t.Fatalf("expected nil, got %+v", b)
 		}
 	})
 	t.Run("empty cmd", func(t *testing.T) {
-		if b := Batch(); b != nil {
+		if b := createFn(); b != nil {
 			t.Fatalf("expected nil, got %+v", b)
 		}
 	})
 	t.Run("single cmd", func(t *testing.T) {
-		b := Batch(Quit)()
+		b := createFn(Quit)()
 		if _, ok := b.(QuitMsg); !ok {
 			t.Fatalf("expected a QuitMsg, got %T", b)
 		}
 	})
 	t.Run("mixed nil cmds", func(t *testing.T) {
-		b := Batch(nil, Quit, nil, Quit, nil, nil)()
-		if l := len(b.(BatchMsg)); l != 2 {
+		b := createFn(nil, Quit, nil, Quit, nil, nil)()
+		if l := len(b.(T)); l != 2 {
 			t.Fatalf("expected a []Cmd with len 2, got %d", l)
 		}
 	})
diff --git a/examples/simple/testdata/TestApp.golden b/examples/simple/testdata/TestApp.golden
index 413aee4..1a7d095 100644
--- a/examples/simple/testdata/TestApp.golden
+++ b/examples/simple/testdata/TestApp.golden
@@ -1,5 +1,7 @@
-[?25l[?2004h
Hi. This program will exit in 10 seconds.
+[?25l[?2004h
Hi. This program will exit in 10 seconds.[K
+[K
+To quit sooner press ctrl-c, or press ctrl-z to suspend...[K
+[K[70D[3AHi. This program will exit in 9 seconds.[K
 
-To quit sooner press ctrl-c, or press ctrl-z to suspend...
-[70D[A[A[A[70D[2KHi. This program will exit in 9 seconds.
-[B[B[70D[2K
[?2004l[?25h[?1002l[?1003l[?1006l
\ No newline at end of file
+
+[70D[2K
[?2004l[?25h[?1002l[?1003l[?1006l
\ No newline at end of file
diff --git a/exec_test.go b/exec_test.go
index d49f0cc..cdeac23 100644
--- a/exec_test.go
+++ b/exec_test.go
@@ -37,6 +37,16 @@ func (m *testExecModel) View() string {
 	return "\n"
 }
 
+type spyRenderer struct {
+	renderer
+	calledReset bool
+}
+
+func (r *spyRenderer) resetLinesRendered() {
+	r.calledReset = true
+	r.renderer.resetLinesRendered()
+}
+
 func TestTeaExec(t *testing.T) {
 	type test struct {
 		name      string
@@ -76,9 +86,14 @@ func TestTeaExec(t *testing.T) {
 			if _, err := p.Run(); err != nil {
 				t.Error(err)
 			}
+			p.renderer = &spyRenderer{renderer: p.renderer}
 
 			if m.err != nil && !test.expectErr {
 				t.Errorf("expected no error, got %v", m.err)
+
+				if !p.renderer.(*spyRenderer).calledReset {
+					t.Error("expected renderer to be reset")
+				}
 			}
 			if m.err == nil && test.expectErr {
 				t.Error("expected error, got nil")
diff --git a/options_test.go b/options_test.go
index ce8d417..60336c6 100644
--- a/options_test.go
+++ b/options_test.go
@@ -2,6 +2,7 @@ package tea
 
 import (
 	"bytes"
+	"context"
 	"os"
 	"sync/atomic"
 	"testing"
@@ -51,6 +52,16 @@ func TestOptions(t *testing.T) {
 		}
 	})
 
+	t.Run("external context", func(t *testing.T) {
+		extCtx, extCancel := context.WithCancel(context.Background())
+		defer extCancel()
+
+		p := NewProgram(nil, WithContext(extCtx))
+		if p.externalCtx != extCtx || p.externalCtx == context.Background() {
+			t.Errorf("expected passed in external context, got default (nil)")
+		}
+	})
+
 	t.Run("input options", func(t *testing.T) {
 		exercise := func(t *testing.T, opt ProgramOption, expect inputType) {
 			p := NewProgram(nil, opt)
diff --git a/screen_test.go b/screen_test.go
index 728cd97..41b8c73 100644
--- a/screen_test.go
+++ b/screen_test.go
@@ -14,47 +14,47 @@ func TestClearMsg(t *testing.T) {
 		{
 			name:     "clear_screen",
 			cmds:     []Cmd{ClearScreen},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[2J\x1b[1;1H\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[2J\x1b[H\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 		{
 			name:     "altscreen",
 			cmds:     []Cmd{EnterAltScreen, ExitAltScreen},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l\x1b[?1049l\x1b[?25l\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 		{
 			name:     "altscreen_autoexit",
 			cmds:     []Cmd{EnterAltScreen},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[?25l\rsuccess\r\n\x1b[2;0H\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l\x1b[H\rsuccess\x1b[K\r\n\x1b[K\x1b[2;H\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h",
 		},
 		{
 			name:     "mouse_cellmotion",
 			cmds:     []Cmd{EnableMouseCellMotion},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[?1002h\x1b[?1006h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[?1002h\x1b[?1006h\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 		{
 			name:     "mouse_allmotion",
 			cmds:     []Cmd{EnableMouseAllMotion},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 		{
 			name:     "mouse_disable",
 			cmds:     []Cmd{EnableMouseAllMotion, DisableMouse},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006l\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 		{
 			name:     "cursor_hide",
 			cmds:     []Cmd{HideCursor},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[?25l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[?25l\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 		{
 			name:     "cursor_hideshow",
 			cmds:     []Cmd{HideCursor, ShowCursor},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[?25l\x1b[?25h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[?25l\x1b[?25h\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 		{
 			name:     "bp_stop_start",
 			cmds:     []Cmd{DisableBracketedPaste, EnableBracketedPaste},
-			expected: "\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
+			expected: "\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[?2004h\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l",
 		},
 	}
 
@@ -66,6 +66,7 @@ func TestClearMsg(t *testing.T) {
 			m := &testModel{}
 			p := NewProgram(m, WithInput(&in), WithOutput(&buf))
 
+			test.cmds = append([]Cmd{func() Msg { return WindowSizeMsg{80, 24} }}, test.cmds...)
 			test.cmds = append(test.cmds, Quit)
 			go p.Send(test.cmds)
 
diff --git a/tea_test.go b/tea_test.go
index 981851b..d4e29d0 100644
--- a/tea_test.go
+++ b/tea_test.go
@@ -4,13 +4,20 @@ import (
 	"bytes"
 	"context"
 	"errors"
+	"sync"
 	"sync/atomic"
 	"testing"
 	"time"
 )
 
+type ctxImplodeMsg struct {
+	cancel context.CancelFunc
+}
+
 type incrementMsg struct{}
 
+type panicMsg struct{}
+
 type testModel struct {
 	executed atomic.Value
 	counter  atomic.Value
@@ -21,7 +28,11 @@ func (m testModel) Init() Cmd {
 }
 
 func (m *testModel) Update(msg Msg) (Model, Cmd) {
-	switch msg.(type) {
+	switch msg := msg.(type) {
+	case ctxImplodeMsg:
+		msg.cancel()
+		time.Sleep(100 * time.Millisecond)
+
 	case incrementMsg:
 		i := m.counter.Load()
 		if i == nil {
@@ -32,6 +43,9 @@ func (m *testModel) Update(msg Msg) (Model, Cmd) {
 
 	case KeyMsg:
 		return m, Quit
+
+	case panicMsg:
+		panic("testing panic behavior")
 	}
 
 	return m, nil
@@ -81,6 +95,106 @@ func TestTeaQuit(t *testing.T) {
 	}
 }
 
+func TestTeaWaitQuit(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	progStarted := make(chan struct{})
+	waitStarted := make(chan struct{})
+	errChan := make(chan error, 1)
+
+	m := &testModel{}
+	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
+
+	go func() {
+		_, err := p.Run()
+		errChan <- err
+	}()
+
+	go func() {
+		for {
+			time.Sleep(time.Millisecond)
+			if m.executed.Load() != nil {
+				close(progStarted)
+
+				<-waitStarted
+				time.Sleep(50 * time.Millisecond)
+				p.Quit()
+
+				return
+			}
+		}
+	}()
+
+	<-progStarted
+
+	var wg sync.WaitGroup
+	for i := 0; i < 5; i++ {
+		wg.Add(1)
+		go func() {
+			p.Wait()
+			wg.Done()
+		}()
+	}
+	close(waitStarted)
+	wg.Wait()
+
+	err := <-errChan
+	if err != nil {
+		t.Fatalf("Expected nil, got %v", err)
+	}
+}
+
+func TestTeaWaitKill(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	progStarted := make(chan struct{})
+	waitStarted := make(chan struct{})
+	errChan := make(chan error, 1)
+
+	m := &testModel{}
+	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
+
+	go func() {
+		_, err := p.Run()
+		errChan <- err
+	}()
+
+	go func() {
+		for {
+			time.Sleep(time.Millisecond)
+			if m.executed.Load() != nil {
+				close(progStarted)
+
+				<-waitStarted
+				time.Sleep(50 * time.Millisecond)
+				p.Kill()
+
+				return
+			}
+		}
+	}()
+
+	<-progStarted
+
+	var wg sync.WaitGroup
+	for i := 0; i < 5; i++ {
+		wg.Add(1)
+		go func() {
+			p.Wait()
+			wg.Done()
+		}()
+	}
+	close(waitStarted)
+	wg.Wait()
+
+	err := <-errChan
+	if !errors.Is(err, ErrProgramKilled) {
+		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
+	}
+}
+
 func TestTeaWithFilter(t *testing.T) {
 	testTeaWithFilter(t, 0)
 	testTeaWithFilter(t, 1)
@@ -138,9 +252,17 @@ func TestTeaKill(t *testing.T) {
 		}
 	}()
 
-	if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) {
+	_, err := p.Run()
+
+	if !errors.Is(err, ErrProgramKilled) {
 		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
 	}
+
+	if errors.Is(err, context.Canceled) {
+		// The end user should not know about the program's internal context state.
+		// The program should only report external context cancellation as a context error.
+		t.Fatalf("Internal context cancellation was reported as context error!")
+	}
 }
 
 func TestTeaContext(t *testing.T) {
@@ -160,6 +282,66 @@ func TestTeaContext(t *testing.T) {
 		}
 	}()
 
+	_, err := p.Run()
+
+	if !errors.Is(err, ErrProgramKilled) {
+		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
+	}
+
+	if !errors.Is(err, context.Canceled) {
+		// The end user should know that their passed in context caused the kill.
+		t.Fatalf("Expected %v, got %v", context.Canceled, err)
+	}
+}
+
+func TestTeaContextImplodeDeadlock(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 {
+				p.Send(ctxImplodeMsg{cancel: cancel})
+				return
+			}
+		}
+	}()
+
+	if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) {
+		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
+	}
+}
+
+func TestTeaContextBatchDeadlock(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	inc := func() Msg {
+		cancel()
+		return incrementMsg{}
+	}
+
+	m := &testModel{}
+	p := NewProgram(m, WithContext(ctx), WithInput(&in), WithOutput(&buf))
+	go func() {
+		for {
+			time.Sleep(time.Millisecond)
+			if m.executed.Load() != nil {
+				batch := make(BatchMsg, 100)
+				for i := range batch {
+					batch[i] = inc
+				}
+				p.Send(batch)
+				return
+			}
+		}
+	}()
+
 	if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) {
 		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
 	}
@@ -267,3 +449,65 @@ func TestTeaNoRun(t *testing.T) {
 	m := &testModel{}
 	NewProgram(m, WithInput(&in), WithOutput(&buf))
 }
+
+func TestTeaPanic(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
+	go func() {
+		for {
+			time.Sleep(time.Millisecond)
+			if m.executed.Load() != nil {
+				p.Send(panicMsg{})
+				return
+			}
+		}
+	}()
+
+	_, err := p.Run()
+
+	if !errors.Is(err, ErrProgramPanic) {
+		t.Fatalf("Expected %v, got %v", ErrProgramPanic, err)
+	}
+
+	if !errors.Is(err, ErrProgramKilled) {
+		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
+	}
+}
+
+func TestTeaGoroutinePanic(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	panicCmd := func() Msg {
+		panic("testing goroutine panic behavior")
+	}
+
+	m := &testModel{}
+	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
+	go func() {
+		for {
+			time.Sleep(time.Millisecond)
+			if m.executed.Load() != nil {
+				batch := make(BatchMsg, 10)
+				for i := range batch {
+					batch[i] = panicCmd
+				}
+				p.Send(batch)
+				return
+			}
+		}
+	}()
+
+	_, err := p.Run()
+
+	if !errors.Is(err, ErrProgramPanic) {
+		t.Fatalf("Expected %v, got %v", ErrProgramPanic, err)
+	}
+
+	if !errors.Is(err, ErrProgramKilled) {
+		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
+	}
+}