Repository: charmbracelet/bubbletea
Pull request #313 (2022-05-12 01:56:00)

docs: add gh-b to readme


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4bebb3a..5d086b2 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,7 +5,7 @@ jobs:
   build:
     strategy:
       matrix:
-        go-version: [~1.13, ^1]
+        go-version: [~1.16, ^1]
         os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
     env:
@@ -25,6 +25,9 @@ jobs:
       - name: Build
         run: go build -v ./...
 
+      - name: Test
+        run: go test ./...
+
       - name: Build examples
         run: go build -v ./...
         working-directory: ./examples
diff --git a/README.md b/README.md
index 9cc4e61..4ee6a9f 100644
--- a/README.md
+++ b/README.md
@@ -298,7 +298,8 @@ For some Bubble Tea programs in production, see:
 * [fork-cleaner](https://github.com/caarlos0/fork-cleaner): cleans up old and inactive forks in your GitHub account
 * [gambit](https://github.com/maaslalani/gambit): play chess in the terminal
 * [gembro](https://git.sr.ht/~rafael/gembro): a mouse-driven Gemini browser
-* [gh-prs](https://www.github.com/dlvhdr/gh-prs): gh cli extension to display a dashboard of PRs
+* [gh-b](https://github.com/joaom00/gh-b): GitHub CLI extension to easily manage your branches
+* [gh-dash](https://www.github.com/dlvhdr/gh-dash): GitHub cli extension to display a dashboard of PRs and issues
 * [gitflow-toolkit](https://github.com/mritd/gitflow-toolkit): a GitFlow submission tool
 * [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser and online markdown stash
 * [gocovsh](https://github.com/orlangure/gocovsh): explore Go coverage reports from the CLI
diff --git a/commands.go b/commands.go
index 8aeab21..99007b2 100644
--- a/commands.go
+++ b/commands.go
@@ -24,6 +24,35 @@ import (
 //   cmd := Every(time.Second, func(t time.Time) Msg {
 //      return TickMsg(t)
 //   })
+//
+// Beginners' note: Every sends a single message and won't automatically
+// dispatch messages at an interval. To do that, you'll want to return another
+// Every command after receiving your tick message. For example:
+//
+//   type TickMsg time.Time
+//
+//   // Send a message every second.
+//   func tickEvery() Cmd {
+//       return Every(time.Second, func(t time.Time) Msg {
+//           return TickMsg(t)
+//       })
+//   }
+//
+//   func (m model) Init() Cmd {
+//       // Start ticking.
+//       return tickEvery()
+//   }
+//
+//   func (m model) Update(msg Msg) (Model, Cmd) {
+//       switch msg.(type) {
+//       case TickMsg:
+//           // Return your Every command again to loop.
+//           return m, tickEvery()
+//       }
+//       return m, nil
+//   }
+//
+// Every is analogous to Tick in the Elm Architecture.
 func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
 	return func() Msg {
 		n := time.Now()
@@ -45,6 +74,33 @@ func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
 //   cmd := Tick(time.Second, func(t time.Time) Msg {
 //      return TickMsg(t)
 //   })
+//
+// Beginners' note: Tick sends a single message and won't automatically
+// dispatch messages at an interval. To do that, you'll want to return another
+// Tick command after receiving your tick message. For example:
+//
+//   type TickMsg time.Time
+//
+//   func doTick() Cmd {
+//       return Tick(time.Second, func(t time.Time) Msg {
+//           return TickMsg(t)
+//       })
+//   }
+//
+//   func (m model) Init() Cmd {
+//       // Start ticking.
+//       return doTick()
+//   }
+//
+//   func (m model) Update(msg Msg) (Model, Cmd) {
+//       switch msg.(type) {
+//       case TickMsg:
+//           // Return your Tick command again to loop.
+//           return m, doTick()
+//       }
+//       return m, nil
+//   }
+//
 func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
 	return func() Msg {
 		t := time.NewTimer(d)
@@ -57,10 +113,10 @@ func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
 // The Msg returned is the first non-nil message returned by a Cmd.
 //
 //   func saveStateCmd() Msg {
-//   	if err := save(); err != nil {
-//    		return errMsg{err}
-//    	}
-//   	return nil
+//      if err := save(); err != nil {
+//          return errMsg{err}
+//      }
+//      return nil
 //   }
 //
 //   cmd := Sequentially(saveStateCmd, Quit)
diff --git a/examples/exec/main.go b/examples/exec/main.go
index 3406cd0..709efe8 100644
--- a/examples/exec/main.go
+++ b/examples/exec/main.go
@@ -11,8 +11,12 @@ import (
 type editorFinishedMsg struct{ err error }
 
 func openEditor() tea.Cmd {
-	c := exec.Command(os.Getenv("EDITOR")) //nolint:gosec
-	return tea.Exec(tea.WrapExecCommand(c), func(err error) tea.Msg {
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		editor = "vim"
+	}
+	c := exec.Command(editor) //nolint:gosec
+	return tea.ExecProcess(c, func(err error) tea.Msg {
 		return editorFinishedMsg{err}
 	})
 }
diff --git a/examples/go.mod b/examples/go.mod
index b501b8b..7851f35 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -3,8 +3,8 @@ module examples
 go 1.13
 
 require (
-	github.com/charmbracelet/bubbles v0.10.3
-	github.com/charmbracelet/bubbletea v0.19.3
+	github.com/charmbracelet/bubbles v0.11.0
+	github.com/charmbracelet/bubbletea v0.21.0
 	github.com/charmbracelet/glamour v0.5.0
 	github.com/charmbracelet/lipgloss v0.5.0
 	github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776
diff --git a/examples/go.sum b/examples/go.sum
index e9df944..19af625 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -4,13 +4,12 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 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.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
-github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
+github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q=
+github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
 github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
 github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
-github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
-github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
-github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
+github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
 github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
 github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
@@ -70,7 +69,6 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GE
 golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/exec.go b/exec.go
new file mode 100644
index 0000000..d1d9d69
--- /dev/null
+++ b/exec.go
@@ -0,0 +1,129 @@
+package tea
+
+import (
+	"io"
+	"os"
+	"os/exec"
+)
+
+// execMsg is used internally to run an ExecCommand sent with Exec.
+type execMsg struct {
+	cmd ExecCommand
+	fn  ExecCallback
+}
+
+// Exec is used to perform arbitrary I/O in a blocking fashion, effectively
+// pausing the Program while execution is runnning and resuming it when
+// execution has completed.
+//
+// Most of the time you'll want to use ExecProcess, which runs an exec.Cmd.
+//
+// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
+func Exec(c ExecCommand, fn ExecCallback) Cmd {
+	return func() Msg {
+		return execMsg{cmd: c, fn: fn}
+	}
+}
+
+// ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively
+// pausing the Program while the command is running. After the *exec.Cmd exists
+// the Program resumes. It's useful for spawning other interactive applications
+// such as editors and shells from within a Program.
+//
+// To produce the command, pass an *exec.Cmd and a function which returns
+// a message containing the error which may have occurred when running the
+// ExecCommand.
+//
+//     type VimFinishedMsg struct { err error }
+//
+//     c := exec.Command("vim", "file.txt")
+//
+//     cmd := ExecProcess(c, func(err error) Msg {
+//         return VimFinishedMsg{err: error}
+//     })
+//
+// Or, if you don't care about errors, you could simply:
+//
+//     cmd := ExecProcess(exec.Command("vim", "file.txt"), nil)
+//
+// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
+func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd {
+	return Exec(wrapExecCommand(c), fn)
+}
+
+// ExecCallback is used when executing an *exec.Command to return a message
+// with an error, which may or may not be nil.
+type ExecCallback func(error) Msg
+
+// ExecCommand can be implemented to execute things in a blocking fashion in
+// the current terminal.
+type ExecCommand interface {
+	Run() error
+	SetStdin(io.Reader)
+	SetStdout(io.Writer)
+	SetStderr(io.Writer)
+}
+
+// wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand
+// interface so it can be used with Exec.
+func wrapExecCommand(c *exec.Cmd) ExecCommand {
+	return &osExecCommand{Cmd: c}
+}
+
+// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand
+// interface.
+type osExecCommand struct{ *exec.Cmd }
+
+// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader.
+func (c *osExecCommand) SetStdin(r io.Reader) {
+	// If unset, have the command use the same input as the terminal.
+	if c.Stdin == nil {
+		c.Stdin = r
+	}
+}
+
+// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer.
+func (c *osExecCommand) SetStdout(w io.Writer) {
+	// If unset, have the command use the same output as the terminal.
+	if c.Stdout == nil {
+		c.Stdout = w
+	}
+}
+
+// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer.
+func (c *osExecCommand) SetStderr(w io.Writer) {
+	// If unset, use stderr for the command's stderr
+	if c.Stderr == nil {
+		c.Stderr = w
+	}
+}
+
+// exec runs an ExecCommand and delivers the results to the program as a Msg.
+func (p *Program) exec(c ExecCommand, fn ExecCallback) {
+	if err := p.ReleaseTerminal(); err != nil {
+		// If we can't release input, abort.
+		if fn != nil {
+			go p.Send(fn(err))
+		}
+		return
+	}
+
+	c.SetStdin(p.input)
+	c.SetStdout(p.output)
+	c.SetStderr(os.Stderr)
+
+	// Execute system command.
+	if err := c.Run(); err != nil {
+		_ = p.RestoreTerminal() // also try to restore the terminal.
+		if fn != nil {
+			go p.Send(fn(err))
+		}
+		return
+	}
+
+	// Have the program re-capture input.
+	err := p.RestoreTerminal()
+	if fn != nil {
+		go p.Send(fn(err))
+	}
+}
diff --git a/key.go b/key.go
index 7f05b87..c210376 100644
--- a/key.go
+++ b/key.go
@@ -198,6 +198,7 @@ const (
 	KeyPgUp
 	KeyPgDown
 	KeyDelete
+	KeySpace
 	KeyCtrlUp
 	KeyCtrlDown
 	KeyCtrlRight
@@ -274,6 +275,7 @@ var keyNames = map[KeyType]string{
 	KeyUp:             "up",
 	KeyDown:           "down",
 	KeyRight:          "right",
+	KeySpace:          " ", // for backwards compatibility
 	KeyLeft:           "left",
 	KeyShiftTab:       "shift+tab",
 	KeyHome:           "home",
@@ -490,35 +492,7 @@ func readInputs(input io.Reader) ([]Msg, error) {
 		return m, nil
 	}
 
-	// Is it a sequence, like an arrow key?
-	if k, ok := sequences[string(buf[:numBytes])]; ok {
-		return []Msg{
-			KeyMsg(k),
-		}, nil
-	}
-
-	// Some of these need special handling.
-	hex := fmt.Sprintf("%x", buf[:numBytes])
-	if k, ok := hexes[hex]; ok {
-		return []Msg{
-			KeyMsg(k),
-		}, nil
-	}
-
-	// Is the alt key pressed? If so, the buffer will be prefixed with an
-	// escape.
-	if numBytes > 1 && buf[0] == 0x1b {
-		// Now remove the initial escape sequence and re-process to get the
-		// character being pressed in combination with alt.
-		c, _ := utf8.DecodeRune(buf[1:])
-		if c == utf8.RuneError {
-			return nil, errors.New("could not decode rune after removing initial escape")
-		}
-		return []Msg{
-			KeyMsg(Key{Alt: true, Type: KeyRunes, Runes: []rune{c}}),
-		}, nil
-	}
-
+	var runeSets [][]rune
 	var runes []rune
 	b := buf[:numBytes]
 
@@ -530,30 +504,64 @@ func readInputs(input io.Reader) ([]Msg, error) {
 		if r == utf8.RuneError {
 			return nil, errors.New("could not decode rune")
 		}
+
+		if r == '\x1b' && len(runes) > 1 {
+			// a new key sequence has started
+			runeSets = append(runeSets, runes)
+			runes = []rune{}
+		}
+
 		runes = append(runes, r)
 		w = width
 	}
+	// add the final set of runes we decoded
+	runeSets = append(runeSets, runes)
 
-	if len(runes) == 0 {
+	if len(runeSets) == 0 {
 		return nil, errors.New("received 0 runes from input")
-	} else if len(runes) > 1 {
-		// We received multiple runes, so we know this isn't a control
-		// character, sequence, and so on.
-		return []Msg{
-			KeyMsg(Key{Type: KeyRunes, Runes: runes}),
-		}, nil
 	}
 
-	// Is the first rune a control character?
-	r := KeyType(runes[0])
-	if numBytes == 1 && r <= keyUS || r == keyDEL {
-		return []Msg{
-			KeyMsg(Key{Type: r}),
-		}, nil
+	var msgs []Msg
+	for _, runes := range runeSets {
+		// Is it a sequence, like an arrow key?
+		if k, ok := sequences[string(runes)]; ok {
+			msgs = append(msgs, KeyMsg(k))
+			continue
+		}
+
+		// Some of these need special handling.
+		hex := fmt.Sprintf("%x", runes)
+		if k, ok := hexes[hex]; ok {
+			msgs = append(msgs, KeyMsg(k))
+			continue
+		}
+
+		// Is the alt key pressed? If so, the buffer will be prefixed with an
+		// escape.
+		if len(runes) > 1 && runes[0] == 0x1b {
+			msgs = append(msgs, KeyMsg(Key{Alt: true, Type: KeyRunes, Runes: runes[1:]}))
+			continue
+		}
+
+		for _, v := range runes {
+			// Is the first rune a control character?
+			r := KeyType(v)
+			if r <= keyUS || r == keyDEL {
+				msgs = append(msgs, KeyMsg(Key{Type: r}))
+				continue
+			}
+
+			// If it's a space, override the type with KeySpace (but still include
+			// the rune).
+			if r == ' ' {
+				msgs = append(msgs, KeyMsg(Key{Type: KeySpace, Runes: []rune{v}}))
+				continue
+			}
+
+			// Welp, just regular, ol' runes.
+			msgs = append(msgs, KeyMsg(Key{Type: KeyRunes, Runes: []rune{v}}))
+		}
 	}
 
-	// Welp, it's just a regular, ol' single rune.
-	return []Msg{
-		KeyMsg(Key{Type: KeyRunes, Runes: runes}),
-	}, nil
+	return msgs, nil
 }
diff --git a/standard_renderer.go b/standard_renderer.go
index 12087bb..4e75403 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -348,7 +348,9 @@ func (r *standardRenderer) handleMessages(msg Msg) {
 	case repaintMsg:
 		// Force a repaint by clearing the render cache as we slide into a
 		// render.
+		r.mtx.Lock()
 		r.repaint()
+		r.mtx.Unlock()
 
 	case WindowSizeMsg:
 		r.mtx.Lock()
@@ -362,7 +364,7 @@ func (r *standardRenderer) handleMessages(msg Msg) {
 		// Force a repaint on the area where the scrollable stuff was in this
 		// update cycle
 		r.mtx.Lock()
-		r.lastRender = ""
+		r.repaint()
 		r.mtx.Unlock()
 
 	case syncScrollAreaMsg:
@@ -373,7 +375,7 @@ func (r *standardRenderer) handleMessages(msg Msg) {
 
 		// Force non-scrolling stuff to repaint in this update cycle
 		r.mtx.Lock()
-		r.lastRender = ""
+		r.repaint()
 		r.mtx.Unlock()
 
 	case scrollUpMsg:
diff --git a/tea.go b/tea.go
index dd744a4..73be9bd 100644
--- a/tea.go
+++ b/tea.go
@@ -14,7 +14,6 @@ import (
 	"fmt"
 	"io"
 	"os"
-	"os/exec"
 	"os/signal"
 	"runtime/debug"
 	"sync"
@@ -246,44 +245,6 @@ func HideCursor() Msg {
 	return hideCursorMsg{}
 }
 
-// Exec runs the given ExecCommand in a blocking fashion, effectively pausing
-// the Program while the command is running. After the *exec.Cmd exists the
-// Program resumes. It's useful for spawning other interactive applications
-// such as editors and shells from within a Program.
-//
-// To produce the command, pass an *exec.Command and a function which returns
-// a message containing the error which may have occurred when running the
-// *exec.Command.
-//
-//     type VimFinishedMsg struct { err error }
-//
-//     c := exec.Command("vim", "file.txt")
-//
-//     cmd := Exec(WrapExecCommand(c), func(err error) Msg {
-//         return VimFinishedMsg{err: error}
-//     })
-//
-// Or, if you don't care about errors you could simply:
-//
-//     cmd := Exec(WrapExecCommand(exec.Command("vim", "file.txt")), nil)
-//
-// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
-func Exec(c ExecCommand, fn ExecCallback) Cmd {
-	return func() Msg {
-		return execMsg{cmd: c, fn: fn}
-	}
-}
-
-// ExecCallback is used when executing an *exec.Command to return a message
-// with an error, which may or may not be nil.
-type ExecCallback func(error) Msg
-
-// execMsg is used internally to run an ExecCommand sent with Exec.
-type execMsg struct {
-	cmd ExecCommand
-	fn  ExecCallback
-}
-
 // hideCursorMsg is an internal command used to hide the cursor. You can send
 // this message with HideCursor.
 type hideCursorMsg struct{}
@@ -542,7 +503,9 @@ func (p *Program) StartReturningModel() (Model, error) {
 				continue
 
 			case WindowSizeMsg:
+				p.mtx.Lock()
 				p.renderer.repaint()
+				p.mtx.Unlock()
 
 			case enterAltScreenMsg:
 				p.EnterAltScreen()
@@ -564,7 +527,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 				hideCursor(p.output)
 
 			case execMsg:
-				// Note: this blocks.
+				// NB: this blocks.
 				p.exec(msg.cmd, msg.fn)
 			}
 
@@ -746,76 +709,3 @@ func (p *Program) RestoreTerminal() error {
 
 	return nil
 }
-
-// ExecCommand can be implemented to execute things in the current
-// terminal using the Exec Cmd.
-type ExecCommand interface {
-	Run() error
-	SetStdin(io.Reader)
-	SetStdout(io.Writer)
-	SetStderr(io.Writer)
-}
-
-// WrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand
-// interface.
-func WrapExecCommand(c *exec.Cmd) ExecCommand {
-	return &osExecCommand{Cmd: c}
-}
-
-// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand
-// interface.
-type osExecCommand struct{ *exec.Cmd }
-
-// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader.
-func (c *osExecCommand) SetStdin(r io.Reader) {
-	// If unset, have the command use the same input as the terminal.
-	if c.Stdin == nil {
-		c.Stdin = r
-	}
-}
-
-// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer.
-func (c *osExecCommand) SetStdout(w io.Writer) {
-	// If unset, have the command use the same output as the terminal.
-	if c.Stdout == nil {
-		c.Stdout = w
-	}
-}
-
-// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer.
-func (c *osExecCommand) SetStderr(w io.Writer) {
-	// If unset, use stderr for the command's stderr
-	if c.Stderr == nil {
-		c.Stderr = w
-	}
-}
-
-// exec runs an ExecCommand and delivers the results to the program as a Msg.
-func (p *Program) exec(c ExecCommand, fn ExecCallback) {
-	if err := p.ReleaseTerminal(); err != nil {
-		// If we can't release input, abort.
-		if fn != nil {
-			go p.Send(fn(err))
-		}
-		return
-	}
-
-	c.SetStdin(p.input)
-	c.SetStdout(p.output)
-	c.SetStderr(os.Stderr)
-
-	// Execute system command.
-	if err := c.Run(); err != nil {
-		_ = p.RestoreTerminal() // also try to restore the terminal.
-		if fn != nil {
-			go p.Send(fn(err))
-		}
-		return
-	}
-
-	// Have the program re-capture input.
-	err := p.RestoreTerminal()
-	if fn != nil {
-		go p.Send(fn(err))
-	}
-}
diff --git a/tutorials/go.mod b/tutorials/go.mod
index 27661c2..6d00a45 100644
--- a/tutorials/go.mod
+++ b/tutorials/go.mod
@@ -2,6 +2,6 @@ module tutorial
 
 go 1.14
 
-require github.com/charmbracelet/bubbletea v0.19.3
+require github.com/charmbracelet/bubbletea v0.21.0
 
 replace github.com/charmbracelet/bubbletea => ../


Test patch:
diff --git a/key_test.go b/key_test.go
index b8ce47d..b74f06b 100644
--- a/key_test.go
+++ b/key_test.go
@@ -8,9 +8,8 @@ import (
 func TestKeyString(t *testing.T) {
 	t.Run("alt+space", func(t *testing.T) {
 		if got := KeyMsg(Key{
-			Type:  KeyRunes,
-			Runes: []rune{' '},
-			Alt:   true,
+			Type: KeySpace,
+			Alt:  true,
 		}).String(); got != "alt+ " {
 			t.Fatalf(`expected a "alt+ ", got %q`, got)
 		}
@@ -36,10 +35,7 @@ func TestKeyString(t *testing.T) {
 
 func TestKeyTypeString(t *testing.T) {
 	t.Run("space", func(t *testing.T) {
-		if got := KeyMsg(Key{
-			Type:  KeyRunes,
-			Runes: []rune{' '},
-		}).String(); got != " " {
+		if got := KeySpace.String(); got != " " {
 			t.Fatalf(`expected a " ", got %q`, got)
 		}
 	})
@@ -52,29 +48,119 @@ func TestKeyTypeString(t *testing.T) {
 }
 
 func TestReadInput(t *testing.T) {
-	for out, in := range map[string][]byte{
-		"a":         {'a'},
-		"ctrl+a":    {byte(keySOH)},
-		"alt+a":     {0x1b, 'a'},
-		"abcd":      {'a', 'b', 'c', 'd'},
-		"up":        []byte("\x1b[A"),
-		"wheel up":  {'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
-		"shift+tab": {'\x1b', '[', 'Z'},
+	type test struct {
+		in  []byte
+		out []Msg
+	}
+	for out, td := range map[string]test{
+		"a": {
+			[]byte{'a'},
+			[]Msg{
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'a'},
+				},
+			},
+		},
+		" ": {
+			[]byte{' '},
+			[]Msg{
+				KeyMsg{
+					Type:  KeySpace,
+					Runes: []rune{' '},
+				},
+			},
+		},
+		"ctrl+a": {
+			[]byte{byte(keySOH)},
+			[]Msg{
+				KeyMsg{
+					Type: KeyCtrlA,
+				},
+			},
+		},
+		"alt+a": {
+			[]byte{byte(0x1b), 'a'},
+			[]Msg{
+				KeyMsg{
+					Type:  KeyRunes,
+					Alt:   true,
+					Runes: []rune{'a'},
+				},
+			},
+		},
+		"abcd": {
+			[]byte{'a', 'b', 'c', 'd'},
+			[]Msg{
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'a'},
+				},
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'b'},
+				},
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'c'},
+				},
+				KeyMsg{
+					Type:  KeyRunes,
+					Runes: []rune{'d'},
+				},
+			},
+		},
+		"up": {
+			[]byte("\x1b[A"),
+			[]Msg{
+				KeyMsg{
+					Type: KeyUp,
+				},
+			},
+		},
+		"wheel up": {
+			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
+			[]Msg{
+				MouseMsg{
+					Type: MouseWheelUp,
+				},
+			},
+		},
+		"shift+tab": {
+			[]byte{'\x1b', '[', 'Z'},
+			[]Msg{
+				KeyMsg{
+					Type: KeyShiftTab,
+				},
+			},
+		},
 	} {
 		t.Run(out, func(t *testing.T) {
-			msgs, err := readInputs(bytes.NewReader(in))
+			msgs, err := readInputs(bytes.NewReader(td.in))
 			if err != nil {
 				t.Fatalf("unexpected error: %v", err)
 			}
-			if len(msgs) == 0 {
-				t.Fatalf("unexpected empty message list")
+			if len(msgs) != len(td.out) {
+				t.Fatalf("unexpected message list length")
 			}
 
-			if m, ok := msgs[0].(KeyMsg); ok && m.String() != out {
-				t.Fatalf(`expected a keymsg %q, got %q`, out, m)
+			if len(msgs) == 1 {
+				if m, ok := msgs[0].(KeyMsg); ok && m.String() != out {
+					t.Fatalf(`expected a keymsg %q, got %q`, out, m)
+				}
 			}
-			if m, ok := msgs[0].(MouseMsg); ok && mouseEventTypes[m.Type] != out {
-				t.Fatalf(`expected a mousemsg %q, got %q`, out, mouseEventTypes[m.Type])
+
+			for i, v := range msgs {
+				if m, ok := v.(KeyMsg); ok &&
+					m.String() != td.out[i].(KeyMsg).String() {
+					t.Fatalf(`expected a keymsg %q, got %q`, td.out[i].(KeyMsg), m)
+				}
+				if m, ok := v.(MouseMsg); ok &&
+					(mouseEventTypes[m.Type] != out || m.Type != td.out[i].(MouseMsg).Type) {
+					t.Fatalf(`expected a mousemsg %q, got %q`,
+						out,
+						mouseEventTypes[td.out[i].(MouseMsg).Type])
+				}
 			}
 		})
 	}
diff --git a/options_test.go b/options_test.go
index 4b75994..1b77977 100644
--- a/options_test.go
+++ b/options_test.go
@@ -18,10 +18,10 @@ func TestOptions(t *testing.T) {
 		var b bytes.Buffer
 		p := NewProgram(nil, WithInput(&b))
 		if p.input != &b {
-			t.Errorf("expected input to custom, got %v", p.output)
+			t.Errorf("expected input to custom, got %v", p.input)
 		}
 		if p.startupOptions&withCustomInput == 0 {
-			t.Errorf("expected startup options to have custom input set, got %v", p.output)
+			t.Errorf("expected startup options to have custom input set, got %v", p.input)
 		}
 	})