Repository: charmbracelet/bubbletea

Issue:
Windows: first character input lost on successive programs
**Describe the bug**

On Windows, if multiple Tea programs are run successively within the same process, the first program runs fine, but subsequent programs lose the first key pressed.  The key is silently eaten, after which point the program appears to operate properly until it terminates.  Then rinse/repeat for subsequent Tea programs.

This does not occur on Linux or Mac.

**Setup**

 - OS: Windows
 - Shell: cmd and Powershell (both affected)
 - Terminal Emulator: Windows Terminal, Windows Powershell Console, Command Prompt
 - Terminal Multiplexer: none

**To Reproduce**

1. Build the example in the source code section for windows
2. Run it
3. Type some keys for the first input prompt ("Test 0") and note that it works fine
4. Type a key for the second input prompt ("Test 1") and note that the first key is eaten, and subsequent keys presses are registered


**Source Code**

```go
package main

import (
	"fmt"

	"github.com/charmbracelet/bubbles/cursor"
	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"
)

func Input(prompt, value string) {
	ti := textinput.New()
	ti.Prompt = prompt
	ti.Focus()
	p := tea.NewProgram(inputModel{textInput: ti})
	p.Run()
}

type inputModel struct {
	textInput textinput.Model
	quitting  bool
}

func (m inputModel) Init() tea.Cmd {
	return nil
}

func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd

	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyEnter:
			m.textInput.Cursor.SetMode(cursor.CursorHide)
			m.quitting = true
			return m, tea.Quit
		}
	}
	m.textInput, cmd = m.textInput.Update(msg)
	return m, cmd
}

func (m inputModel) View() string {
	str := m.textInput.View()
	if m.quitting {
		return str + "\n"
	}
	return str
}

func main() {
	for i := range 2 {
		Input(fmt.Sprintf("Test %d: ", i), "default")
	}
}
```

**Expected behavior**

The first key on subsequent Tea programs should not be lost.

**Screenshots**

![image](https://github.com/user-attachments/assets/ebb27587-525b-48c5-ac4b-8fe00beffbb2)



Hints from issue comments:
@Denish3436: hi @jtackaberry I would like to work on this, could you assign me @meowgorithm: Go for it, @Denish3436 ✌️@Denish3436: Hii @meowgorithm , can you please review my PR @meowgorithm: Hey @Denish3436! Ayman's reviewed it in #1180. Let's track progress over there.@aymanbagabas: @Denish3436 This should be fixed in `v2-exp`@nojaf: Hi @aymanbagabas, I'm still getting that problem with the `v2-exp` branch on Windows.

Example file:

```go
package main

import (
	"fmt"
	"log"
	"os"

	tea "github.com/charmbracelet/bubbletea/v2"
)

type model struct {
	v string
}

func NewModel() model {
	return model{v: ""}
}

func (m model) Init() (tea.Model, tea.Cmd) {
	return m, nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	// Is it a key press?
	case tea.KeyMsg:
		log.Println("key pressed:", msg.String())

		switch msg.String() {
		case "ctrl+c", "enter":
			return m, tea.Quit
		default:
			m.v += msg.String()
		}
	}

	return m, nil
}

func (m model) View() string {
	return "> " + m.v
}

func main() {
	file := "debug.log"

	// Check if the file exists
	if _, err := os.Stat(file); err == nil {
		// File exists, attempt to remove it
		if err := os.Remove(file); err != nil {
			// Handle error during removal
			panic(err)
		}
	} else if !os.IsNotExist(err) {
		// Handle unexpected error during Stat
		panic(err)
	}

	f, err := tea.LogToFile(file, "debug")
	if err != nil {
		fmt.Println("fatal:", err)
		os.Exit(1)
	}
	defer f.Close()

	p := tea.NewProgram(NewModel())
	if _, err := p.Run(); err != nil {
		fmt.Printf("Alas, there's been an error: %v", err)
		os.Exit(1)
	}

	p2 := tea.NewProgram(NewModel())
	if _, err := p2.Run(); err != nil {
		fmt.Printf("Alas, there's been an error 2: %v", err)
		os.Exit(1)
	}
}
```

A keypress is swallowed for the second program and each key is duplicated in the stdout:

Actual key presses: `a`,`b`,`c`,`enter`,`d`,`e`,`f`,`enter`

```
debug 2024/11/27 16:29:30 key pressed: a
debug 2024/11/27 16:29:30 key pressed: a
debug 2024/11/27 16:29:31 key pressed: b
debug 2024/11/27 16:29:31 key pressed: b
debug 2024/11/27 16:29:31 key pressed: c
debug 2024/11/27 16:29:31 key pressed: c
debug 2024/11/27 16:29:32 key pressed: enter
debug 2024/11/27 16:29:32 key pressed: enter
debug 2024/11/27 16:29:35 key pressed: e
debug 2024/11/27 16:29:35 key pressed: e
debug 2024/11/27 16:29:36 key pressed: f
debug 2024/11/27 16:29:36 key pressed: f
debug 2024/11/27 16:29:36 key pressed: enter
debug 2024/11/27 16:29:36 key pressed: enter
``` 

Any pointers?@aymanbagabas: > Hi @aymanbagabas, I'm still getting that problem with the `v2-exp` branch on Windows.

Hi @nojaf. You're getting duplicate keys because you're listening for `tea.KeyMsg` which accounts for **both** `tea.KeyPressMsg` _and_ `tea.KeyReleaseMsg`.

This should fix that for you:

```diff
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
```

Now, running your examples with the latest `v2-exp` branch gives me this output. 
```
PS C:\Users\Ayman\Source\charmbracelet\bubbletea\examples> Get-Content -path .\debug.log -wait
debug 2024/11/27 11:23:29 key pressed: a
debug 2024/11/27 11:23:30 key pressed: b
debug 2024/11/27 11:23:30 key pressed: c
debug 2024/11/27 11:23:33 key pressed: d
debug 2024/11/27 11:23:34 key pressed: enter
debug 2024/11/27 11:23:35 key pressed: e
debug 2024/11/27 11:23:36 key pressed: f
debug 2024/11/27 11:23:39 key pressed: g
debug 2024/11/27 11:23:40 key pressed: h
debug 2024/11/27 11:23:41 key pressed: enter
```

I'm using Windows Terminal Preview on Windows 11 with the default PowerShell version.@nojaf: Hey @aymanbagabas, thanks for the quick feedback!

The `KeyPressMsg` fix does indeed solve the duplicate keys.
As for the missing one, it does still pop up in `vscode` termninal for me.
Not in the Windows Terminal but the thing vscode uses. (both using latest `pwsh`)

![bubbletea_vscode](https://github.com/user-attachments/assets/c6c2174e-e496-49b7-b621-6db1436f631c)

```
debug 2024/11/27 17:42:53 key pressed: a
debug 2024/11/27 17:42:53 key pressed: b
debug 2024/11/27 17:42:54 key pressed: c
debug 2024/11/27 17:42:54 key pressed: enter
debug 2024/11/27 17:42:56 key pressed: e
debug 2024/11/27 17:42:56 key pressed: f
debug 2024/11/27 17:42:56 key pressed: enter
```

I did press `d` and it is still missing there.@LinusMoser: I'm seeing a similar issue on Windows, where the first key after a `tea.NewProgram().Run()` is swallowed. The following snippet causes the first character of the `fmt.Scanln` to be ignored in my case:
```go
_, err := tea.NewProgram(list.NewList()).Run()
if err!=nil {
	return nil, err
}
input := ""
fmt.Scanln(&input)
```

When investigating the problem, I found that in the [ìnputreader_windows.go](https://github.com/charmbracelet/bubbletea/blob/eb7e3d0884f5976bbd94144bf3495ecb935dbce4/inputreader_windows.go) a custom coninput reader for windows is implemented. I don't really understand why this is done, as far as I understand it this inputreader file exists because reading from os.Stdin cannot be unblocked without an explicit write to it (which causes exactly the symptoms I have). To solve the issue with the blocked os.Stdin there is a platform specific implementation on `"github.com/muesli/cancelreader"` which is actually already in use as a fallback reader. After removing the custom inputreader and the custom coninput reader loop in [key_windows.go](https://github.com/charmbracelet/bubbletea/blob/eb7e3d0884f5976bbd94144bf3495ecb935dbce4/key_windows.go) the issue resolved itself as it now only relies on the cancelreader which correctly cancels upon cancellation.

I have a working fork at https://github.com/megakuul/bubbletea. My question is: why is a custom Windows coninput reader used? I'm not deeply familiar with the project and might be missing some requirements tied to the custom reader.@aymanbagabas: Hey @LinusMoser, could you try the v2-exp branch and see if it solves your issue? You can do `go get github.com/charmbracelet/bubbletea/v2@v2-exp`. Keep in mind that this is still a prerelease.@awoodbeck: I ran into this issue last week on Windows and did some debugging. What I found was that the read loop goroutine was blocked [reading from input](https://github.com/charmbracelet/bubbletea/blob/main/key_windows.go#L28) and did not properly return when the program's `Run` method returned. This caused the very next input to be swallowed up by the read loop, at which time the read loop would return because [its context was canceled](https://github.com/charmbracelet/bubbletea/blob/main/key_windows.go#L85).

The reason the input reader didn't unblock is likely because it's making a [windows.CancelIo](https://github.com/charmbracelet/bubbletea/blob/main/inputreader_windows.go#L60) call on the input handle. Per Windows' [docs](https://learn.microsoft.com/en-us/windows/win32/fileio/cancelio), `CancelIo` will not cancel I/O operations across threads. Since the read loop is in its own goroutine, it's very likely on a separate thread than the one calling `windows.CancelIo()`. [CancelIoEx](https://learn.microsoft.com/en-us/windows/win32/fileio/cancelioex-func) should properly close the input handle, unblock the input reader, and terminate the read loop goroutine.

The above PR adds the `CancelIoEx` call, which resolved the issue for me locally.@LinusMoser: @aymanbagabas with the v2-exp branch it does not solve the issue, instead it seems to receive keyboard messages even if no keys are pressed (notice that it selects the first list item immediately without pressing any key):

https://github.com/user-attachments/assets/8d9dbc25-528b-4154-9b49-2377c6d3c3c9

For reference:
```bash
require (
	github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250131212117-b2e3cc537170
	github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250203222705-2e91ec2235cd
	github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250127205353-a4c540623882
)
```

I think the issue in v1 is actually solved by the proposal from @awoodbeck, this does also work for me. @aymanbagabas: > Hey [@aymanbagabas](https://github.com/aymanbagabas), thanks for the quick feedback!
> 
> The `KeyPressMsg` fix does indeed solve the duplicate keys. As for the missing one, it does still pop up in `vscode` termninal for me. Not in the Windows Terminal but the thing vscode uses. (both using latest `pwsh`)


I can always reproduce the issue in the VSCode terminal, even with the fix @awoodbeck introduced, same goes for v2-exp. The fix works on Windows Terminal and cmd.exe but not VSCode terminal. Which leads me to think that this is related to VSCode terminal 🤔 @aymanbagabas: > [@aymanbagabas](https://github.com/aymanbagabas) with the v2-exp branch it does not solve the issue, instead it seems to receive keyboard messages even if no keys are pressed (notice that it selects the first list item immediately without pressing any key):
> 

@LinusMoser Could you provide the example you used with v2-exp?@LinusMoser: @aymanbagabas I have previously tested it directly in my application code. Here is a very simple example that reproduces the issue:
[https://github.com/LinusMoser/bubbletea-v2-windows-input-issue](https://github.com/LinusMoser/bubbletea-v2-windows-input-issue)

It seems to only occur when I read from `stdin` before opening the tea application. For testing, I used the Windows terminal `wt.exe` with powershell@joshallenit: Hi, this is still an issue with `1.3.4`, `v2-exp`, and `v2.0.0-alpha.2`. I can see the fix from `@awoodbeck` in `1.3.4`. @meowgorithm: @joshallenit appreciate the heads up; we’ll re-open to investigate; Windows is a priority for us.@joshallenit: The issue in my testing is that `windows.CancelIoEx` returns an error "Element not found", and the character goes to the "cancelled" coninput.ReadNConsoleInputs call, which is then discarded because "con.isCanceled()"

This issue leads to be believe that cancelling console input on Windows is not possible, and a new approach is required: https://github.com/microsoft/terminal/issues/12143@aymanbagabas: > The issue in my testing is that `windows.CancelIoEx` returns an error "Element not found", and the character goes to the "cancelled" coninput.ReadNConsoleInputs call, which is then discarded because "con.isCanceled()"

Hey @joshallenit, thank you for bringing this to our attention again. While I cannot reproduce this with the latest v2-exp 2ce107c, could you provide a minimal reproducible example for this case?

I'm testing this against v2-exp using the following example:

```go
package main

import (
	"fmt"

	"github.com/charmbracelet/bubbles/v2/textinput"
	tea "github.com/charmbracelet/bubbletea/v2"
)

func Input(prompt, value string) {
	ti := textinput.New()
	ti.Prompt = prompt
	ti.Focus()
	p := tea.NewProgram(inputModel{textInput: ti})
	p.Run()
}

type inputModel struct {
	textInput textinput.Model
	quitting  bool
}

func (m inputModel) Init() tea.Cmd {
	return nil
}

func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd

	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		switch msg.Code {
		case tea.KeyEnter:
			m.quitting = true
			return m, tea.Quit
		}
	}
	m.textInput, cmd = m.textInput.Update(msg)
	return m, cmd
}

func (m inputModel) View() string {
	str := m.textInput.View()
	if m.quitting {
		return str + "\n"
	}
	return str
}

func main() {
	for i := 0; i < 2; i++ {
		Input(fmt.Sprintf("Test %d: ", i), "default")
	}
}
```@joshallenit: > [...]
> Hey [@joshallenit](https://github.com/joshallenit), thank you for bringing this to our attention again. While I cannot reproduce this with the latest v2-exp [2ce107c](https://github.com/charmbracelet/bubbletea/commit/2ce107c85fedc825fcba0c16846d43ca484d3093), could you provide a minimal reproducible example for this case?
> 
[...]

I can reproduce the issue using Git Bash. Have you tested your example using Git Bash as the terminal?

Solution 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)
+	}
+}