Repository: charmbracelet/bubbletea
Pull request #1368 (2025-03-25 04:46:01)

Fix: Windows: first character input lost on successive programs 
Explanation here: https://github.com/charmbracelet/bubbletea/issues/1167#issuecomment-2728251993

Note: the previous PR was closed by accident https://github.com/charmbracelet/bubbletea/pull/1355

Note: I don't think it is possible to unit test this in a meaningful way because to reproduce the issue requires using console input, but please correct me if I'm wrong.

<!-- 

#### Testing

<img src="XXXCOPYURLXXX" alt="" width="300"/> 

<video src="XXXCOPYURLXXX" alt="" width="300"/>

| Before | After |
| ------------- | ------------- |
| <img src="XXXCOPYURLXXX" alt="" width="300"/> | <img src="XXXCOPYURLXXX" alt="" width="300"/> |


| Before | After |
| ------------- | ------------- |
| <video src="XXXCOPYURLXXX" alt="" width="300"/> | <video src="XXXCOPYURLXXX" alt="" width="300"/> |

<details>
  <summary>Detailed Section (click me)</summary>
  
</details>

-->

#### Issue: https://github.com/charmbracelet/bubbletea/issues/1167



Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index d42a1a5..d28c064 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -37,7 +37,7 @@ updates:
     labels:
       - "dependencies"
     commit-message:
-      prefix: "feat"
+      prefix: "chore"
       include: "scope"
 
   - package-ecosystem: "gomod"
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/.golangci.yml b/.golangci.yml
index 90c5c08..4fac29c 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,26 +1,12 @@
+version: "2"
 run:
   tests: false
-
-issues:
-  include:
-    - EXC0001
-    - EXC0005
-    - EXC0011
-    - EXC0012
-    - EXC0013
-
-  max-issues-per-linter: 0
-  max-same-issues: 0
-
 linters:
   enable:
     - bodyclose
     - exhaustive
     - goconst
     - godot
-    - godox
-    - gofumpt
-    - goimports
     - gomoddirectives
     - goprintffuncname
     - gosec
@@ -39,3 +25,16 @@ linters:
     - 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/README.md b/README.md
index 3ab2adc..b3acf90 100644
--- a/README.md
+++ b/README.md
@@ -2,15 +2,14 @@
 
 <p>
     <picture>
-      <source media="(prefers-color-scheme: light)" srcset="https://github.com/user-attachments/assets/a600b1be-9b1a-48e8-a2a4-3f3917240db1" width="308">
-      <source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/f1ec37dd-5a0c-4ae8-ab9e-daf50fef2a13" width="312">
-      <img src="https://github.com/user-attachments/assets/a600b1be-9b1a-48e8-a2a4-3f3917240db1" width="308" />
+      <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
diff --git a/examples/go.mod b/examples/go.mod
index 290eedc..1905da6 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -1,14 +1,15 @@
 module examples
 
-go 1.21
+go 1.23.0
+
 toolchain go1.24.1
 
 require (
-	github.com/charmbracelet/bubbles v0.20.0
-	github.com/charmbracelet/bubbletea v1.2.0
-	github.com/charmbracelet/glamour v0.9.1
+	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 v1.1.0
+	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 +23,10 @@ require (
 	github.com/aymanbagabas/go-udiff v0.2.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
-	github.com/charmbracelet/x/ansi v0.8.0 // indirect
-	github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
-	github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // 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
@@ -42,11 +44,11 @@ require (
 	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.36.0 // indirect
-	golang.org/x/sync v0.12.0 // indirect
-	golang.org/x/sys v0.31.0 // indirect
-	golang.org/x/term v0.30.0 // indirect
-	golang.org/x/text v0.23.0 // 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 e1603ca..297e85d 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -14,22 +14,24 @@ 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/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.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
-github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
+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 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.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
-github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
-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/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.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -82,15 +84,15 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC
 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.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
-golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
-golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
-golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
-golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+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/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 5b07f5d..044d688 100644
--- a/go.mod
+++ b/go.mod
@@ -6,14 +6,14 @@ toolchain go1.23.7
 
 require (
 	github.com/charmbracelet/lipgloss v1.1.0
-	github.com/charmbracelet/x/ansi v0.8.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.12.0
-	golang.org/x/sys v0.31.0
+	golang.org/x/sync v0.15.0
+	golang.org/x/sys v0.33.0
 )
 
 require (
diff --git a/go.sum b/go.sum
index e34b33a..c760968 100644
--- a/go.sum
+++ b/go.sum
@@ -4,8 +4,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
 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.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
-github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+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=
@@ -33,11 +33,11 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
 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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.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_windows.go b/inputreader_windows.go
index 9af8942..d76617d 100644
--- a/inputreader_windows.go
+++ b/inputreader_windows.go
@@ -67,6 +67,8 @@ func newInputReader(r io.Reader, enableMouse bool) (cancelreader.CancelReader, e
 func (r *conInputReader) Cancel() bool {
 	r.setCanceled()
 
+	// 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
 }
 
diff --git a/key_windows.go b/key_windows.go
index bd16e83..ac98373 100644
--- a/key_windows.go
+++ b/key_windows.go
@@ -7,6 +7,7 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"time"
 
 	"github.com/erikgeiser/coninput"
 	localereader "github.com/mattn/go-localereader"
@@ -25,14 +26,10 @@ func readConInputs(ctx context.Context, msgsch chan<- Msg, con *conInputReader)
 	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.conin, 16)
+		events, err := peekAndReadConsInput(con)
 		if err != nil {
-			if con.isCanceled() {
-				return cancelreader.ErrCanceled
-			}
-			return fmt.Errorf("read coninput events: %w", err)
+			return err
 		}
-
 		for _, event := range events {
 			var msgs []Msg
 			switch e := event.Unwrap().(type) {
@@ -94,6 +91,50 @@ func readConInputs(ctx context.Context, msgsch chan<- Msg, con *conInputReader)
 	}
 }
 
+// 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
@@ -276,6 +317,46 @@ 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:
 		switch {
 		case e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED) && e.ControlKeyState.Contains(coninput.RIGHT_ALT_PRESSED):
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 c509353..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
 	}
 }
 
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/standard_renderer.go b/standard_renderer.go
index 45e8c82..d6dce6d 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -545,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.
diff --git a/tea.go b/tea.go
index 0bc915e..c4866f2 100644
--- a/tea.go
+++ b/tea.go
@@ -27,6 +27,9 @@ import (
 	"golang.org/x/sync/errgroup"
 )
 
+// 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")
 
@@ -147,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
 
@@ -243,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 {
@@ -346,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.
@@ -422,7 +435,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				// work.
 				if runtime.GOOS == "windows" && !p.mouseMode {
 					p.mouseMode = true
-					p.initCancelReader(true) //nolint:errcheck
+					p.initCancelReader(true) //nolint:errcheck,gosec
 				}
 
 			case disableMouseMsg:
@@ -433,7 +446,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				// mouse events.
 				if runtime.GOOS == "windows" && p.mouseMode {
 					p.mouseMode = false
-					p.initCancelReader(true) //nolint:errcheck
+					p.initCancelReader(true) //nolint:errcheck,gosec
 				}
 
 			case showCursorMsg:
@@ -460,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
 
@@ -483,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
 						}
@@ -506,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
 		}
 	}
@@ -515,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()
 
@@ -568,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.
@@ -645,11 +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 || err != nil
-	if killed && err == nil {
-		err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err())
+
+	if err == nil && len(p.errs) > 0 {
+		err = <-p.errs // Drain a leftover error in case eventLoop crashed
 	}
-	if err == nil {
+
+	killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil
+	if killed {
+		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())
 	}
@@ -704,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.
@@ -717,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()
 
@@ -744,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


Test patch:
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/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)
+	}
+}