Repository: charmbracelet/bubbletea
Pull request #1080 (2024-08-12 21:24:32)

refactor: reimplement the event loop with a sequence parser
Currently, Bubble Tea uses a simple lookup table to detect input events. Here, we're introducing an actual input sequence parser instead of simply using a lookup table. This will allow Bubble Tea programs to read all sorts of input events such Kitty keyboard, background color, mode report, and all sorts of ANSI sequence input events.

This PR includes the following changes:
- Support clipboard OSC52 read messages (`OSC 52 ?`)
- Support terminal foreground/background/cursor color report messages (OSC10, OSC11, OSC12)
- Support terminal focus events (mode 1004)
- Deprecate the old `KeyMsg` API in favor of `KeyPressMsg` and `KeyReleaseMsg`
  - `KeyType` const values are different now. Programs that use int value comparison **will** break. E.g. `key.Type == 13` where `13` is the control code for `CR` that corresponds to the <kbd>enter</kbd> key. (BREAKING CHANGE!)
  - Bubble Tea will send two messages for key presses, the first of type `KeyMsg` and the second of type `KeyPressMsg`. This is to keep backwards compatibility and _not_ break the API
  - `tea.Key` contains breaking changes (BREAKING CHANGE!)
- Deprecate `MouseMsg` in favor of `MouseClickMsg`, `MouseReleaseMsg`, `MouseWheelMsg`, and `MouseMotionMsg`
  - Bubble Tea will send two messages for mouse clicks, releases, wheel, and motion. The first message will be a `MouseMsg` type. And the second will have the new corresponding type. This is to keep backwards compatibility and _not_ break the API
  - `tea.Mouse` contains breaking changes (BREAKING CHANGE!)
- Support reading Kitty keyboard reports (reading the results of sending `CSI ? u` to the terminal)
- Support reading Kitty keyboard and fixterms keys `CSI u`
- Support reading terminal mode reports (DECRPM)
- Bracketed-paste messages now have their own message type `PasteMsg`. Use `PasteStartMsg` and `PasteEndMsg` to listen to the start/end of the paste message.
  - Bubble Tea will send two messages for bracketed-paste, the first is of type `KeyMsg` and the second is of type `PasteMsg`. This is to keep backwards compatibility and _not_ break the API
- Support more obscure key input sequences found in URxvt and others
- Support reading termcap/terminfo capabilities through `XTGETTCAP`. These capabilities will get reported as `TermcapMsg`
- Support reading terminfo databases for key input sequences (disabled for now)
- Support reading [Win32 Input Mode keys](https://github.com/microsoft/terminal/blob/main/doc/specs/%234999%20-%20Improved%20keyboard%20handling%20in%20Conpty.md#win32-input-mode-sequences)
- Support reading Xterm `modifyOtherKeys` keys

TODO:
- [x] Parse multi-rune graphemes as one `KeyPressMsg` storing it in `key.Runes`
- [x] Kitty keyboard startup settings and options https://github.com/charmbracelet/bubbletea/pull/1083
- [x] Xterm modify other keys startup settings and options https://github.com/charmbracelet/bubbletea/pull/1084
- [x] Focus events startup settings and options https://github.com/charmbracelet/bubbletea/pull/1081
- [x] Fg/bg/cursor terminal color startup settings and options https://github.com/charmbracelet/bubbletea/pull/1085

Supersedes: https://github.com/charmbracelet/bubbletea/pull/1079
Supersedes: https://github.com/charmbracelet/bubbletea/issues/1014
Related: https://github.com/charmbracelet/bubbletea/issues/869
Related: https://github.com/charmbracelet/bubbletea/issues/163
Related: https://github.com/charmbracelet/bubbletea/issues/918
Related: https://github.com/charmbracelet/bubbletea/issues/850
Related: https://github.com/charmbracelet/bubbletea/issues/207


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fabbf10..7f82f8c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -31,16 +31,19 @@ jobs:
         run: go test ./...
 
       - name: Build examples
+        if: ${{ matrix.go-version != '~1.18' }}
         run: |
           go mod tidy
           go build -v ./...
         working-directory: ./examples
 
       - name: Test examples
+        if: ${{ matrix.go-version != '~1.18' }}
         run: go test -v ./...
         working-directory: ./examples
 
       - name: Build tutorials
+        if: ${{ matrix.go-version != '~1.18' }}
         run: |
           go mod tidy
           go build -v ./...
diff --git a/inputreader_other.go b/cancelreader_other.go
similarity index 66%
rename from inputreader_other.go
rename to cancelreader_other.go
index 8e63a87..5c6e277 100644
--- a/inputreader_other.go
+++ b/cancelreader_other.go
@@ -9,6 +9,6 @@ import (
 	"github.com/muesli/cancelreader"
 )
 
-func newInputReader(r io.Reader) (cancelreader.CancelReader, error) {
+func newCancelreader(r io.Reader) (cancelreader.CancelReader, error) {
 	return cancelreader.NewReader(r)
 }
diff --git a/cancelreader_windows.go b/cancelreader_windows.go
new file mode 100644
index 0000000..15bc3f6
--- /dev/null
+++ b/cancelreader_windows.go
@@ -0,0 +1,217 @@
+//go:build windows
+// +build windows
+
+package tea
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"sync"
+	"time"
+
+	"github.com/erikgeiser/coninput"
+	"github.com/muesli/cancelreader"
+	"golang.org/x/sys/windows"
+)
+
+type conInputReader struct {
+	cancelMixin
+
+	conin       windows.Handle
+	cancelEvent windows.Handle
+
+	originalMode uint32
+
+	// blockingReadSignal is used to signal that a blocking read is in progress.
+	blockingReadSignal chan struct{}
+}
+
+var _ cancelreader.CancelReader = &conInputReader{}
+
+func newCancelreader(r io.Reader) (cancelreader.CancelReader, error) {
+	fallback := func(io.Reader) (cancelreader.CancelReader, error) {
+		return cancelreader.NewReader(r)
+	}
+
+	var dummy uint32
+	if f, ok := r.(cancelreader.File); !ok || f.Fd() != os.Stdin.Fd() ||
+		// If data was piped to the standard input, it does not emit events
+		// anymore. We can detect this if the console mode cannot be set anymore,
+		// in this case, we fallback to the default cancelreader implementation.
+		windows.GetConsoleMode(windows.Handle(f.Fd()), &dummy) != nil {
+		return fallback(r)
+	}
+
+	conin, err := coninput.NewStdinHandle()
+	if err != nil {
+		return fallback(r)
+	}
+
+	originalMode, err := prepareConsole(conin,
+		windows.ENABLE_MOUSE_INPUT,
+		windows.ENABLE_WINDOW_INPUT,
+		windows.ENABLE_EXTENDED_FLAGS,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("failed to prepare console input: %w", err)
+	}
+
+	cancelEvent, err := windows.CreateEvent(nil, 0, 0, nil)
+	if err != nil {
+		return nil, fmt.Errorf("create stop event: %w", err)
+	}
+
+	return &conInputReader{
+		conin:              conin,
+		cancelEvent:        cancelEvent,
+		originalMode:       originalMode,
+		blockingReadSignal: make(chan struct{}, 1),
+	}, nil
+}
+
+// Cancel implements cancelreader.CancelReader.
+func (r *conInputReader) Cancel() bool {
+	r.setCanceled()
+
+	select {
+	case r.blockingReadSignal <- struct{}{}:
+		err := windows.SetEvent(r.cancelEvent)
+		if err != nil {
+			return false
+		}
+		<-r.blockingReadSignal
+	case <-time.After(100 * time.Millisecond):
+		// Read() hangs in a GetOverlappedResult which is likely due to
+		// WaitForMultipleObjects returning without input being available
+		// so we cannot cancel this ongoing read.
+		return false
+	}
+
+	return true
+}
+
+// Close implements cancelreader.CancelReader.
+func (r *conInputReader) Close() error {
+	err := windows.CloseHandle(r.cancelEvent)
+	if err != nil {
+		return fmt.Errorf("closing cancel event handle: %w", err)
+	}
+
+	if r.originalMode != 0 {
+		err := windows.SetConsoleMode(r.conin, r.originalMode)
+		if err != nil {
+			return fmt.Errorf("reset console mode: %w", err)
+		}
+	}
+
+	return nil
+}
+
+// Read implements cancelreader.CancelReader.
+func (r *conInputReader) Read(data []byte) (n int, err error) {
+	if r.isCanceled() {
+		return 0, cancelreader.ErrCanceled
+	}
+
+	err = waitForInput(r.conin, r.cancelEvent)
+	if err != nil {
+		return 0, err
+	}
+
+	if r.isCanceled() {
+		return 0, cancelreader.ErrCanceled
+	}
+
+	r.blockingReadSignal <- struct{}{}
+	n, err = overlappedReader(r.conin).Read(data)
+	<-r.blockingReadSignal
+
+	return
+}
+
+func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
+	err = windows.GetConsoleMode(input, &originalMode)
+	if err != nil {
+		return 0, fmt.Errorf("get console mode: %w", err)
+	}
+
+	newMode := coninput.AddInputModes(0, modes...)
+
+	err = windows.SetConsoleMode(input, newMode)
+	if err != nil {
+		return 0, fmt.Errorf("set console mode: %w", err)
+	}
+
+	return originalMode, nil
+}
+
+func waitForInput(conin, cancel windows.Handle) error {
+	event, err := windows.WaitForMultipleObjects([]windows.Handle{conin, cancel}, false, windows.INFINITE)
+	switch {
+	case windows.WAIT_OBJECT_0 <= event && event < windows.WAIT_OBJECT_0+2:
+		if event == windows.WAIT_OBJECT_0+1 {
+			return cancelreader.ErrCanceled
+		}
+
+		if event == windows.WAIT_OBJECT_0 {
+			return nil
+		}
+
+		return fmt.Errorf("unexpected wait object is ready: %d", event-windows.WAIT_OBJECT_0)
+	case windows.WAIT_ABANDONED <= event && event < windows.WAIT_ABANDONED+2:
+		return fmt.Errorf("abandoned")
+	case event == uint32(windows.WAIT_TIMEOUT):
+		return fmt.Errorf("timeout")
+	case event == windows.WAIT_FAILED:
+		return fmt.Errorf("failed")
+	default:
+		return fmt.Errorf("unexpected error: %w", err)
+	}
+}
+
+// cancelMixin represents a goroutine-safe cancelation status.
+type cancelMixin struct {
+	unsafeCanceled bool
+	lock           sync.Mutex
+}
+
+func (c *cancelMixin) setCanceled() {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	c.unsafeCanceled = true
+}
+
+func (c *cancelMixin) isCanceled() bool {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	return c.unsafeCanceled
+}
+
+type overlappedReader windows.Handle
+
+// Read performs an overlapping read fom a windows.Handle.
+func (r overlappedReader) Read(data []byte) (int, error) {
+	hevent, err := windows.CreateEvent(nil, 0, 0, nil)
+	if err != nil {
+		return 0, fmt.Errorf("create event: %w", err)
+	}
+
+	overlapped := windows.Overlapped{HEvent: hevent}
+
+	var n uint32
+
+	err = windows.ReadFile(windows.Handle(r), data, &n, &overlapped)
+	if err != nil && err != windows.ERROR_IO_PENDING {
+		return int(n), err
+	}
+
+	err = windows.GetOverlappedResult(windows.Handle(r), &overlapped, &n, true)
+	if err != nil {
+		return int(n), nil
+	}
+
+	return int(n), nil
+}
diff --git a/clipboard.go b/clipboard.go
new file mode 100644
index 0000000..9963b34
--- /dev/null
+++ b/clipboard.go
@@ -0,0 +1,11 @@
+package tea
+
+// ClipboardMsg is a clipboard read message event.
+// This message is emitted when a terminal receives an OSC52 clipboard read
+// message event.
+type ClipboardMsg string
+
+// String returns the string representation of the clipboard message.
+func (e ClipboardMsg) String() string {
+	return string(e)
+}
diff --git a/color.go b/color.go
new file mode 100644
index 0000000..0b49185
--- /dev/null
+++ b/color.go
@@ -0,0 +1,82 @@
+package tea
+
+import (
+	"fmt"
+	"image/color"
+	"strconv"
+	"strings"
+)
+
+// ForegroundColorMsg represents a foreground color message.
+// This message is emitted when the program requests the terminal foreground
+// color.
+type ForegroundColorMsg struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e ForegroundColorMsg) String() string {
+	return colorToHex(e)
+}
+
+// BackgroundColorMsg represents a background color message.
+// This message is emitted when the program requests the terminal background
+// color.
+type BackgroundColorMsg struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e BackgroundColorMsg) String() string {
+	return colorToHex(e)
+}
+
+// CursorColorMsg represents a cursor color change message.
+// This message is emitted when the program requests the terminal cursor color.
+type CursorColorMsg struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e CursorColorMsg) String() string {
+	return colorToHex(e)
+}
+
+type shiftable interface {
+	~uint | ~uint16 | ~uint32 | ~uint64
+}
+
+func shift[T shiftable](x T) T {
+	if x > 0xff {
+		x >>= 8
+	}
+	return x
+}
+
+func colorToHex(c color.Color) string {
+	r, g, b, _ := c.RGBA()
+	return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b))
+}
+
+func xParseColor(s string) color.Color {
+	switch {
+	case strings.HasPrefix(s, "rgb:"):
+		parts := strings.Split(s[4:], "/")
+		if len(parts) != 3 {
+			return color.Black
+		}
+
+		r, _ := strconv.ParseUint(parts[0], 16, 32)
+		g, _ := strconv.ParseUint(parts[1], 16, 32)
+		b, _ := strconv.ParseUint(parts[2], 16, 32)
+
+		return color.RGBA{uint8(shift(r)), uint8(shift(g)), uint8(shift(b)), 255}
+	case strings.HasPrefix(s, "rgba:"):
+		parts := strings.Split(s[5:], "/")
+		if len(parts) != 4 {
+			return color.Black
+		}
+
+		r, _ := strconv.ParseUint(parts[0], 16, 32)
+		g, _ := strconv.ParseUint(parts[1], 16, 32)
+		b, _ := strconv.ParseUint(parts[2], 16, 32)
+		a, _ := strconv.ParseUint(parts[3], 16, 32)
+
+		return color.RGBA{uint8(shift(r)), uint8(shift(g)), uint8(shift(b)), uint8(shift(a))}
+	}
+	return color.Black
+}
diff --git a/cursor.go b/cursor.go
new file mode 100644
index 0000000..17a580f
--- /dev/null
+++ b/cursor.go
@@ -0,0 +1,10 @@
+package tea
+
+// CursorPositionMsg is a message that represents the terminal cursor position.
+type CursorPositionMsg struct {
+	// Row is the row number.
+	Row int
+
+	// Column is the column number.
+	Column int
+}
diff --git a/da1.go b/da1.go
new file mode 100644
index 0000000..dc42ee6
--- /dev/null
+++ b/da1.go
@@ -0,0 +1,19 @@
+package tea
+
+import "github.com/charmbracelet/x/ansi"
+
+// PrimaryDeviceAttributesMsg is a message that represents the terminal primary
+// device attributes.
+type PrimaryDeviceAttributesMsg []uint
+
+func parsePrimaryDevAttrs(csi *ansi.CsiSequence) Msg {
+	// Primary Device Attributes
+	da1 := make(PrimaryDeviceAttributesMsg, len(csi.Params))
+	csi.Range(func(i int, p int, hasMore bool) bool {
+		if !hasMore {
+			da1[i] = uint(p)
+		}
+		return true
+	})
+	return da1
+}
diff --git a/driver.go b/driver.go
new file mode 100644
index 0000000..b31a7c2
--- /dev/null
+++ b/driver.go
@@ -0,0 +1,129 @@
+package tea
+
+import (
+	"bytes"
+	"io"
+	"unicode/utf8"
+
+	"github.com/erikgeiser/coninput"
+	"github.com/muesli/cancelreader"
+)
+
+// driver represents an ANSI terminal input driver.
+// It reads input events and parses ANSI sequences from the terminal input
+// buffer.
+type driver struct {
+	rd    cancelreader.CancelReader
+	table map[string]Key // table is a lookup table for key sequences.
+
+	term string // term is the terminal name $TERM.
+
+	// paste is the bracketed paste mode buffer.
+	// When nil, bracketed paste mode is disabled.
+	paste []byte
+
+	buf [256]byte // do we need a larger buffer?
+
+	// prevMouseState keeps track of the previous mouse state to determine mouse
+	// up button events.
+	prevMouseState coninput.ButtonState // nolint: unused
+
+	// lastWinsizeEvent keeps track of the last window size event to prevent
+	// multiple size events from firing.
+	lastWinsizeEvent coninput.WindowBufferSizeEventRecord // nolint: unused
+
+	flags int // control the behavior of the driver.
+}
+
+// newDriver returns a new ANSI input driver.
+// This driver uses ANSI control codes compatible with VT100/VT200 terminals,
+// and XTerm. It supports reading Terminfo databases to overwrite the default
+// key sequences.
+func newDriver(r io.Reader, term string, flags int) (*driver, error) {
+	d := new(driver)
+	cr, err := newCancelreader(r)
+	if err != nil {
+		return nil, err
+	}
+
+	d.rd = cr
+	d.table = buildKeysTable(flags, term)
+	d.term = term
+	d.flags = flags
+	return d, nil
+}
+
+// Cancel cancels the underlying reader.
+func (d *driver) Cancel() bool {
+	return d.rd.Cancel()
+}
+
+// Close closes the underlying reader.
+func (d *driver) Close() error {
+	return d.rd.Close()
+}
+
+func (d *driver) readEvents() (msgs []Msg, err error) {
+	nb, err := d.rd.Read(d.buf[:])
+	if err != nil {
+		return nil, err
+	}
+
+	buf := d.buf[:nb]
+
+	// Lookup table first
+	if bytes.HasPrefix(buf, []byte{'\x1b'}) {
+		if k, ok := d.table[string(buf)]; ok {
+			msgs = append(msgs, KeyPressMsg(k))
+			return
+		}
+	}
+
+	var i int
+	for i < len(buf) {
+		nb, ev := parseSequence(buf[i:])
+
+		// Handle bracketed-paste
+		if d.paste != nil {
+			if _, ok := ev.(PasteEndMsg); !ok {
+				d.paste = append(d.paste, buf[i])
+				i++
+				continue
+			}
+		}
+
+		switch ev.(type) {
+		case UnknownMsg:
+			// If the sequence is not recognized by the parser, try looking it up.
+			if k, ok := d.table[string(buf[i:i+nb])]; ok {
+				ev = KeyPressMsg(k)
+			}
+		case PasteStartMsg:
+			d.paste = []byte{}
+		case PasteEndMsg:
+			// Decode the captured data into runes.
+			var paste []rune
+			for len(d.paste) > 0 {
+				r, w := utf8.DecodeRune(d.paste)
+				if r != utf8.RuneError {
+					paste = append(paste, r)
+				}
+				d.paste = d.paste[w:]
+			}
+			d.paste = nil // reset the buffer
+			msgs = append(msgs, PasteMsg(paste))
+		case nil:
+			i++
+			continue
+		}
+
+		if mevs, ok := ev.(multiMsg); ok {
+			msgs = append(msgs, []Msg(mevs)...)
+		} else {
+			msgs = append(msgs, ev)
+		}
+		i += nb
+	}
+
+	return
+}
diff --git a/driver_other.go b/driver_other.go
new file mode 100644
index 0000000..8e67ec1
--- /dev/null
+++ b/driver_other.go
@@ -0,0 +1,11 @@
+//go:build !windows
+// +build !windows
+
+package tea
+
+// ReadEvents reads input events from the terminal.
+//
+// It reads the events available in the input buffer and returns them.
+func (d *driver) ReadEvents() ([]Msg, error) {
+	return d.readEvents()
+}
diff --git a/driver_windows.go b/driver_windows.go
new file mode 100644
index 0000000..0f257ed
--- /dev/null
+++ b/driver_windows.go
@@ -0,0 +1,272 @@
+//go:build windows
+// +build windows
+
+package tea
+
+import (
+	"errors"
+	"fmt"
+	"unicode/utf16"
+
+	"github.com/charmbracelet/x/ansi"
+	"github.com/erikgeiser/coninput"
+	"golang.org/x/sys/windows"
+)
+
+// ReadEvents reads input events from the terminal.
+//
+// It reads the events available in the input buffer and returns them.
+func (d *driver) ReadEvents() ([]Msg, error) {
+	events, err := d.handleConInput(coninput.ReadConsoleInput)
+	if errors.Is(err, errNotConInputReader) {
+		return d.readEvents()
+	}
+	return events, err
+}
+
+var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader")
+
+func (d *driver) handleConInput(
+	finput func(windows.Handle, []coninput.InputRecord) (uint32, error),
+) ([]Msg, error) {
+	cc, ok := d.rd.(*conInputReader)
+	if !ok {
+		return nil, errNotConInputReader
+	}
+
+	// read up to 256 events, this is to allow for sequences events reported as
+	// key events.
+	var events [256]coninput.InputRecord
+	_, err := finput(cc.conin, events[:])
+	if err != nil {
+		return nil, fmt.Errorf("read coninput events: %w", err)
+	}
+
+	var evs []Msg
+	for _, event := range events {
+		if e := parseConInputEvent(event, &d.prevMouseState, &d.lastWinsizeEvent); e != nil {
+			evs = append(evs, e)
+		}
+	}
+
+	return d.detectConInputQuerySequences(evs), nil
+}
+
+// Using ConInput API, Windows Terminal responds to sequence query events with
+// KEY_EVENT_RECORDs so we need to collect them and parse them as a single
+// sequence.
+// Is this a hack?
+func (d *driver) detectConInputQuerySequences(events []Msg) []Msg {
+	var newEvents []Msg
+	start, end := -1, -1
+
+loop:
+	for i, e := range events {
+		switch e := e.(type) {
+		case KeyPressMsg:
+			switch e.Rune() {
+			case ansi.ESC, ansi.CSI, ansi.OSC, ansi.DCS, ansi.APC:
+				// start of a sequence
+				if start == -1 {
+					start = i
+				}
+			}
+		default:
+			break loop
+		}
+		end = i
+	}
+
+	if start == -1 || end <= start {
+		return events
+	}
+
+	var seq []byte
+	for i := start; i <= end; i++ {
+		switch e := events[i].(type) {
+		case KeyPressMsg:
+			seq = append(seq, byte(e.Rune()))
+		}
+	}
+
+	n, seqevent := parseSequence(seq)
+	switch seqevent.(type) {
+	case UnknownMsg:
+		// We're not interested in unknown events
+	default:
+		if start+n > len(events) {
+			return events
+		}
+		newEvents = events[:start]
+		newEvents = append(newEvents, seqevent)
+		newEvents = append(newEvents, events[start+n:]...)
+		return d.detectConInputQuerySequences(newEvents)
+	}
+
+	return events
+}
+
+func parseConInputEvent(event coninput.InputRecord, ps *coninput.ButtonState, ws *coninput.WindowBufferSizeEventRecord) Msg {
+	switch e := event.Unwrap().(type) {
+	case coninput.KeyEventRecord:
+		event := parseWin32InputKeyEvent(e.VirtualKeyCode, e.VirtualScanCode,
+			e.Char, e.KeyDown, e.ControlKeyState, e.RepeatCount)
+
+		var key Key
+		switch event := event.(type) {
+		case KeyPressMsg:
+			key = Key(event)
+		case KeyReleaseMsg:
+			key = Key(event)
+		default:
+			return nil
+		}
+
+		// If the key is not printable, return the event as is
+		// (e.g. function keys, arrows, etc.)
+		// Otherwise, try to translate it to a rune based on the active keyboard
+		// layout.
+		if len(key.Runes) == 0 {
+			return event
+		}
+
+		// Always use US layout for translation
+		// This is to follow the behavior of the Kitty Keyboard base layout
+		// feature :eye_roll:
+		// https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/windows-language-pack-default-values?view=windows-11
+		const usLayout = 0x409
+
+		// Translate key to rune
+		var keyState [256]byte
+		var utf16Buf [16]uint16
+		const dontChangeKernelKeyboardLayout = 0x4
+		ret := windows.ToUnicodeEx(
+			uint32(e.VirtualKeyCode),
+			uint32(e.VirtualScanCode),
+			&keyState[0],
+			&utf16Buf[0],
+			int32(len(utf16Buf)),
+			dontChangeKernelKeyboardLayout,
+			usLayout,
+		)
+
+		// -1 indicates a dead key
+		// 0 indicates no translation for this key
+		if ret < 1 {
+			return event
+		}
+
+		runes := utf16.Decode(utf16Buf[:ret])
+		if len(runes) != 1 {
+			// Key doesn't translate to a single rune
+			return event
+		}
+
+		key.baseRune = runes[0]
+		if e.KeyDown {
+			return KeyPressMsg(key)
+		}
+
+		return KeyReleaseMsg(key)
+
+	case coninput.WindowBufferSizeEventRecord:
+		if e != *ws {
+			*ws = e
+			return WindowSizeMsg{
+				Width:  int(e.Size.X),
+				Height: int(e.Size.Y),
+			}
+		}
+	case coninput.MouseEventRecord:
+		mevent := mouseEvent(*ps, e)
+		*ps = e.ButtonState
+		return mevent
+	case coninput.FocusEventRecord, coninput.MenuEventRecord:
+		// ignore
+	}
+	return nil
+}
+
+func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, isRelease bool) {
+	btn := p ^ s
+	if btn&s == 0 {
+		isRelease = true
+	}
+
+	if btn == 0 {
+		switch {
+		case s&coninput.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
+			button = MouseLeft
+		case s&coninput.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
+			button = MouseMiddle
+		case s&coninput.RIGHTMOST_BUTTON_PRESSED > 0:
+			button = MouseRight
+		case s&coninput.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
+			button = MouseBackward
+		case s&coninput.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
+			button = MouseForward
+		}
+		return
+	}
+
+	switch btn {
+	case coninput.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
+		button = MouseLeft
+	case coninput.RIGHTMOST_BUTTON_PRESSED: // right button
+		button = MouseRight
+	case coninput.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
+		button = MouseMiddle
+	case coninput.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
+		button = MouseBackward
+	case coninput.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
+		button = MouseForward
+	}
+
+	return
+}
+
+func mouseEvent(p coninput.ButtonState, e coninput.MouseEventRecord) (ev Msg) {
+	var mod KeyMod
+	var isRelease bool
+	if e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED) {
+		mod |= ModAlt
+	}
+	if e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED) {
+		mod |= ModCtrl
+	}
+	if e.ControlKeyState.Contains(coninput.SHIFT_PRESSED) {
+		mod |= ModShift
+	}
+	m := Mouse{
+		X:   int(e.MousePositon.X),
+		Y:   int(e.MousePositon.Y),
+		Mod: mod,
+	}
+	switch e.EventFlags {
+	case coninput.CLICK, coninput.DOUBLE_CLICK:
+		m.Button, isRelease = mouseEventButton(p, e.ButtonState)
+	case coninput.MOUSE_WHEELED:
+		if e.WheelDirection > 0 {
+			m.Button = MouseWheelUp
+		} else {
+			m.Button = MouseWheelDown
+		}
+	case coninput.MOUSE_HWHEELED:
+		if e.WheelDirection > 0 {
+			m.Button = MouseWheelRight
+		} else {
+			m.Button = MouseWheelLeft
+		}
+	case coninput.MOUSE_MOVED:
+		m.Button, _ = mouseEventButton(p, e.ButtonState)
+		return MouseMotionMsg(m)
+	}
+
+	if isWheel(m.Button) {
+		return MouseWheelMsg(m)
+	} else if isRelease {
+		return MouseReleaseMsg(m)
+	}
+
+	return MouseClickMsg(m)
+}
diff --git a/examples/go.mod b/examples/go.mod
index b33f5c9..40598a5 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -1,6 +1,8 @@
 module examples
 
-go 1.18
+go 1.21
+
+toolchain go1.22.5
 
 require (
 	github.com/charmbracelet/bubbles v0.18.0
@@ -29,7 +31,6 @@ require (
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/gorilla/css v1.0.1 // indirect
-	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
@@ -43,9 +44,8 @@ require (
 	github.com/yuin/goldmark-emoji v1.0.3 // indirect
 	golang.org/x/net v0.27.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
-	golang.org/x/sys v0.23.0 // indirect
+	golang.org/x/sys v0.24.0 // indirect
 	golang.org/x/term v0.22.0 // indirect
-	golang.org/x/text v0.16.0 // indirect
 )
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/examples/go.sum b/examples/go.sum
index 0ab3f68..75ebdfe 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -1,7 +1,9 @@
 github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
+github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
 github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -41,13 +43,13 @@ github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOf
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
-github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -75,15 +77,14 @@ github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRla
 github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
 github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
 golang.org/x/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.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
 golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
 golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
 golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 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.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
-golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
+golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
 golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
diff --git a/examples/query-term/main.go b/examples/query-term/main.go
new file mode 100644
index 0000000..b42f36b
--- /dev/null
+++ b/examples/query-term/main.go
@@ -0,0 +1,97 @@
+// This example uses a textinput to send the terminal ANSI sequences to query
+// it for capabilities.
+package main
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"strconv"
+	"strings"
+	"unicode"
+
+	"github.com/charmbracelet/bubbles/textinput"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func newModel() model {
+	ti := textinput.NewModel()
+	ti.Focus()
+	ti.CharLimit = 156
+	ti.Width = 20
+	return model{input: ti}
+}
+
+type model struct {
+	input textinput.Model
+	err   error
+}
+
+func (m model) Init() tea.Cmd {
+	return textinput.Blink
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		m.err = nil
+		switch msg.String() {
+		case "ctrl+c":
+			return m, tea.Quit
+		case "enter":
+			// Write the sequence to the terminal
+			val := m.input.Value()
+			val = "\"" + val + "\""
+
+			// unescape the sequence
+			seq, err := strconv.Unquote(val)
+			if err != nil {
+				m.err = err
+				return m, nil
+			}
+
+			if !strings.HasPrefix(seq, "\x1b") {
+				m.err = fmt.Errorf("sequence is not an ANSI escape sequence")
+				return m, nil
+			}
+
+			// write the sequence to the terminal
+			return m, func() tea.Msg {
+				io.WriteString(os.Stdout, seq)
+				return nil
+			}
+		}
+	default:
+		typ := strings.TrimPrefix(fmt.Sprintf("%T", msg), "tea.")
+		if len(typ) > 0 && unicode.IsUpper(rune(typ[0])) {
+			// Only log messages that are exported types
+			cmds = append(cmds, tea.Printf("Received message: %T\n", msg))
+		}
+	}
+
+	var cmd tea.Cmd
+	m.input, cmd = m.input.Update(msg)
+	cmds = append(cmds, cmd)
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m model) View() string {
+	var s strings.Builder
+	s.WriteString(m.input.View())
+	if m.err != nil {
+		s.WriteString("\n\nError: " + m.err.Error())
+	}
+	s.WriteString("\n\nPress ctrl+c to quit, enter to write the sequence to terminal")
+	return s.String()
+}
+
+func main() {
+	p := tea.NewProgram(newModel())
+	if _, err := p.Run(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/focus.go b/focus.go
new file mode 100644
index 0000000..4d34bea
--- /dev/null
+++ b/focus.go
@@ -0,0 +1,9 @@
+package tea
+
+// FocusMsg represents a terminal focus message.
+// This occurs when the terminal gains focus.
+type FocusMsg struct{}
+
+// BlurMsg represents a terminal blur message.
+// This occurs when the terminal loses focus.
+type BlurMsg struct{}
diff --git a/go.mod b/go.mod
index 837102c..34bcfa4 100644
--- a/go.mod
+++ b/go.mod
@@ -6,9 +6,9 @@ require (
 	github.com/charmbracelet/x/ansi v0.1.4
 	github.com/charmbracelet/x/term v0.1.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
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
 	golang.org/x/sync v0.8.0
 	golang.org/x/sys v0.24.0
 )
@@ -18,6 +18,4 @@ require (
 	github.com/charmbracelet/x/windows v0.1.0 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
-	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
-	golang.org/x/text v0.3.8 // indirect
 )
diff --git a/go.sum b/go.sum
index d07e6ad..afe8f92 100644
--- a/go.sum
+++ b/go.sum
@@ -8,8 +8,6 @@ github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wp
 github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
-github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
-github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -27,5 +25,3 @@ golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
 golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-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/input.go b/input.go
new file mode 100644
index 0000000..69ac277
--- /dev/null
+++ b/input.go
@@ -0,0 +1,26 @@
+package tea
+
+import (
+	"fmt"
+	"strings"
+)
+
+// UnknownMsg represents an unknown message.
+type UnknownMsg string
+
+// String returns a string representation of the unknown message.
+func (e UnknownMsg) String() string {
+	return fmt.Sprintf("%q", string(e))
+}
+
+// multiMsg represents multiple messages event.
+type multiMsg []Msg
+
+// String returns a string representation of the multiple messages event.
+func (e multiMsg) String() string {
+	var sb strings.Builder
+	for _, ev := range e {
+		sb.WriteString(fmt.Sprintf("%v\n", ev))
+	}
+	return sb.String()
+}
diff --git a/inputreader_windows.go b/inputreader_windows.go
deleted file mode 100644
index 449df47..0000000
--- a/inputreader_windows.go
+++ /dev/null
@@ -1,107 +0,0 @@
-//go:build windows
-// +build windows
-
-package tea
-
-import (
-	"fmt"
-	"io"
-	"os"
-	"sync"
-
-	"github.com/charmbracelet/x/term"
-	"github.com/erikgeiser/coninput"
-	"github.com/muesli/cancelreader"
-	"golang.org/x/sys/windows"
-)
-
-type conInputReader struct {
-	cancelMixin
-
-	conin windows.Handle
-
-	originalMode uint32
-}
-
-var _ cancelreader.CancelReader = &conInputReader{}
-
-func newInputReader(r io.Reader) (cancelreader.CancelReader, error) {
-	fallback := func(io.Reader) (cancelreader.CancelReader, error) {
-		return cancelreader.NewReader(r)
-	}
-	if f, ok := r.(term.File); !ok || f.Fd() != os.Stdin.Fd() {
-		return fallback(r)
-	}
-
-	conin, err := coninput.NewStdinHandle()
-	if err != nil {
-		return fallback(r)
-	}
-
-	originalMode, err := prepareConsole(conin,
-		windows.ENABLE_MOUSE_INPUT,
-		windows.ENABLE_WINDOW_INPUT,
-		windows.ENABLE_EXTENDED_FLAGS,
-	)
-	if err != nil {
-		return nil, fmt.Errorf("failed to prepare console input: %w", err)
-	}
-
-	return &conInputReader{
-		conin:        conin,
-		originalMode: originalMode,
-	}, nil
-}
-
-// Cancel implements cancelreader.CancelReader.
-func (r *conInputReader) Cancel() bool {
-	r.setCanceled()
-
-	return windows.CancelIo(r.conin) == nil
-}
-
-// Close implements cancelreader.CancelReader.
-func (r *conInputReader) Close() error {
-	if r.originalMode != 0 {
-		err := windows.SetConsoleMode(r.conin, r.originalMode)
-		if err != nil {
-			return fmt.Errorf("reset console mode: %w", err)
-		}
-	}
-
-	return nil
-}
-
-// Read implements cancelreader.CancelReader.
-func (*conInputReader) Read(_ []byte) (n int, err error) {
-	return 0, nil
-}
-
-func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
-	err = windows.GetConsoleMode(input, &originalMode)
-	if err != nil {
-		return 0, fmt.Errorf("get console mode: %w", err)
-	}
-
-	newMode := coninput.AddInputModes(0, modes...)
-
-	err = windows.SetConsoleMode(input, newMode)
-	if err != nil {
-		return 0, fmt.Errorf("set console mode: %w", err)
-	}
-
-	return originalMode, nil
-}
-
-// cancelMixin represents a goroutine-safe cancelation status.
-type cancelMixin struct {
-	unsafeCanceled bool
-	lock           sync.Mutex
-}
-
-func (c *cancelMixin) setCanceled() {
-	c.lock.Lock()
-	defer c.lock.Unlock()
-
-	c.unsafeCanceled = true
-}
diff --git a/key.go b/key.go
index 89a588a..3367941 100644
--- a/key.go
+++ b/key.go
@@ -1,240 +1,92 @@
 package tea
 
-import (
-	"context"
-	"fmt"
-	"io"
-	"regexp"
-	"strings"
-	"unicode/utf8"
-)
-
-// KeyMsg contains information about a keypress. KeyMsgs are always sent to
-// the program's update function. There are a couple general patterns you could
-// use to check for keypresses:
-//
-//	// Switch on the string representation of the key (shorter)
-//	switch msg := msg.(type) {
-//	case KeyMsg:
-//	    switch msg.String() {
-//	    case "enter":
-//	        fmt.Println("you pressed enter!")
-//	    case "a":
-//	        fmt.Println("you pressed a!")
-//	    }
-//	}
-//
-//	// Switch on the key type (more foolproof)
-//	switch msg := msg.(type) {
-//	case KeyMsg:
-//	    switch msg.Type {
-//	    case KeyEnter:
-//	        fmt.Println("you pressed enter!")
-//	    case KeyRunes:
-//	        switch string(msg.Runes) {
-//	        case "a":
-//	            fmt.Println("you pressed a!")
-//	        }
-//	    }
-//	}
+// KeyType indicates whether the key is a special key or runes. Special
+// keys are things like KeyEnter, KeyBackspace, and so on. Runes keys are just
+// regular characters like 'a', '你', 'ض', '🦄', and so on.
 //
-// Note that Key.Runes will always contain at least one character, so you can
-// always safely call Key.Runes[0]. In most cases Key.Runes will only contain
-// one character, though certain input method editors (most notably Chinese
-// IMEs) can input multiple runes at once.
-type KeyMsg Key
-
-// String returns a string representation for a key message. It's safe (and
-// encouraged) for use in key comparison.
-func (k KeyMsg) String() (str string) {
-	return Key(k).String()
-}
-
-// Key contains information about a keypress.
-type Key struct {
-	Type  KeyType
-	Runes []rune
-	Alt   bool
-	Paste bool
-}
-
-// String returns a friendly string representation for a key. It's safe (and
-// encouraged) for use in key comparison.
-//
-//	k := Key{Type: KeyEnter}
-//	fmt.Println(k)
-//	// Output: enter
-func (k Key) String() (str string) {
-	var buf strings.Builder
-	if k.Alt {
-		buf.WriteString("alt+")
-	}
-	if k.Type == KeyRunes {
-		if k.Paste {
-			// Note: bubbles/keys bindings currently do string compares to
-			// recognize shortcuts. Since pasted text should never activate
-			// shortcuts, we need to ensure that the binding code doesn't
-			// match Key events that result from pastes. We achieve this
-			// here by enclosing pastes in '[...]' so that the string
-			// comparison in Matches() fails in that case.
-			buf.WriteByte('[')
-		}
-		buf.WriteString(string(k.Runes))
-		if k.Paste {
-			buf.WriteByte(']')
-		}
-		return buf.String()
-	} else if s, ok := keyNames[k.Type]; ok {
-		buf.WriteString(s)
-		return buf.String()
-	}
-	return ""
-}
-
-// KeyType indicates the key pressed, such as KeyEnter or KeyBreak or KeyCtrlC.
-// All other keys will be type KeyRunes. To get the rune value, check the Rune
-// method on a Key struct, or use the Key.String() method:
-//
-//	k := Key{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}
+//	k := Key{Type: KeyRunes, Runes: []rune{'A'}, Mod: ModShift}
 //	if k.Type == KeyRunes {
 //
 //	    fmt.Println(k.Runes)
-//	    // Output: a
+//	    // Output: A
 //
 //	    fmt.Println(k.String())
-//	    // Output: alt+a
+//	    // Output: shift+a
 //
 //	}
 type KeyType int
 
-func (k KeyType) String() (str string) {
-	if s, ok := keyNames[k]; ok {
-		return s
-	}
-	return ""
-}
-
-// Control keys. We could do this with an iota, but the values are very
-// specific, so we set the values explicitly to avoid any confusion.
-//
-// See also:
-// https://en.wikipedia.org/wiki/C0_and_C1_control_codes
+// Special key symbols.
 const (
-	keyNUL KeyType = 0   // null, \0
-	keySOH KeyType = 1   // start of heading
-	keySTX KeyType = 2   // start of text
-	keyETX KeyType = 3   // break, ctrl+c
-	keyEOT KeyType = 4   // end of transmission
-	keyENQ KeyType = 5   // enquiry
-	keyACK KeyType = 6   // acknowledge
-	keyBEL KeyType = 7   // bell, \a
-	keyBS  KeyType = 8   // backspace
-	keyHT  KeyType = 9   // horizontal tabulation, \t
-	keyLF  KeyType = 10  // line feed, \n
-	keyVT  KeyType = 11  // vertical tabulation \v
-	keyFF  KeyType = 12  // form feed \f
-	keyCR  KeyType = 13  // carriage return, \r
-	keySO  KeyType = 14  // shift out
-	keySI  KeyType = 15  // shift in
-	keyDLE KeyType = 16  // data link escape
-	keyDC1 KeyType = 17  // device control one
-	keyDC2 KeyType = 18  // device control two
-	keyDC3 KeyType = 19  // device control three
-	keyDC4 KeyType = 20  // device control four
-	keyNAK KeyType = 21  // negative acknowledge
-	keySYN KeyType = 22  // synchronous idle
-	keyETB KeyType = 23  // end of transmission block
-	keyCAN KeyType = 24  // cancel
-	keyEM  KeyType = 25  // end of medium
-	keySUB KeyType = 26  // substitution
-	keyESC KeyType = 27  // escape, \e
-	keyFS  KeyType = 28  // file separator
-	keyGS  KeyType = 29  // group separator
-	keyRS  KeyType = 30  // record separator
-	keyUS  KeyType = 31  // unit separator
-	keyDEL KeyType = 127 // delete. on most systems this is mapped to backspace, I hear
-)
+	// KeyRunes indicates that the key represents rune(s), like 'a', 'b', 'c',
+	// and so on.
+	KeyRunes KeyType = iota
 
-// Control key aliases.
-const (
-	KeyNull      KeyType = keyNUL
-	KeyBreak     KeyType = keyETX
-	KeyEnter     KeyType = keyCR
-	KeyBackspace KeyType = keyDEL
-	KeyTab       KeyType = keyHT
-	KeyEsc       KeyType = keyESC
-	KeyEscape    KeyType = keyESC
-
-	KeyCtrlAt           KeyType = keyNUL // ctrl+@
-	KeyCtrlA            KeyType = keySOH
-	KeyCtrlB            KeyType = keySTX
-	KeyCtrlC            KeyType = keyETX
-	KeyCtrlD            KeyType = keyEOT
-	KeyCtrlE            KeyType = keyENQ
-	KeyCtrlF            KeyType = keyACK
-	KeyCtrlG            KeyType = keyBEL
-	KeyCtrlH            KeyType = keyBS
-	KeyCtrlI            KeyType = keyHT
-	KeyCtrlJ            KeyType = keyLF
-	KeyCtrlK            KeyType = keyVT
-	KeyCtrlL            KeyType = keyFF
-	KeyCtrlM            KeyType = keyCR
-	KeyCtrlN            KeyType = keySO
-	KeyCtrlO            KeyType = keySI
-	KeyCtrlP            KeyType = keyDLE
-	KeyCtrlQ            KeyType = keyDC1
-	KeyCtrlR            KeyType = keyDC2
-	KeyCtrlS            KeyType = keyDC3
-	KeyCtrlT            KeyType = keyDC4
-	KeyCtrlU            KeyType = keyNAK
-	KeyCtrlV            KeyType = keySYN
-	KeyCtrlW            KeyType = keyETB
-	KeyCtrlX            KeyType = keyCAN
-	KeyCtrlY            KeyType = keyEM
-	KeyCtrlZ            KeyType = keySUB
-	KeyCtrlOpenBracket  KeyType = keyESC // ctrl+[
-	KeyCtrlBackslash    KeyType = keyFS  // ctrl+\
-	KeyCtrlCloseBracket KeyType = keyGS  // ctrl+]
-	KeyCtrlCaret        KeyType = keyRS  // ctrl+^
-	KeyCtrlUnderscore   KeyType = keyUS  // ctrl+_
-	KeyCtrlQuestionMark KeyType = keyDEL // ctrl+?
-)
+	// Special names in C0
+
+	KeyBackspace
+	KeyTab
+	KeyEnter
+	KeyEscape
+
+	// Special names in G0
+
+	KeySpace
+	KeyDelete
+
+	// Special keys
 
-// Other keys.
-const (
-	KeyRunes KeyType = -(iota + 1)
 	KeyUp
 	KeyDown
 	KeyRight
 	KeyLeft
-	KeyShiftTab
-	KeyHome
-	KeyEnd
+	KeyBegin
+	KeyFind
+	KeyInsert
+	KeySelect
 	KeyPgUp
 	KeyPgDown
-	KeyCtrlPgUp
-	KeyCtrlPgDown
-	KeyDelete
-	KeyInsert
-	KeySpace
-	KeyCtrlUp
-	KeyCtrlDown
-	KeyCtrlRight
-	KeyCtrlLeft
-	KeyCtrlHome
-	KeyCtrlEnd
-	KeyShiftUp
-	KeyShiftDown
-	KeyShiftRight
-	KeyShiftLeft
-	KeyShiftHome
-	KeyShiftEnd
-	KeyCtrlShiftUp
-	KeyCtrlShiftDown
-	KeyCtrlShiftLeft
-	KeyCtrlShiftRight
-	KeyCtrlShiftHome
-	KeyCtrlShiftEnd
+	KeyHome
+	KeyEnd
+
+	// Keypad keys
+
+	KeyKpEnter
+	KeyKpEqual
+	KeyKpMultiply
+	KeyKpPlus
+	KeyKpComma
+	KeyKpMinus
+	KeyKpDecimal
+	KeyKpDivide
+	KeyKp0
+	KeyKp1
+	KeyKp2
+	KeyKp3
+	KeyKp4
+	KeyKp5
+	KeyKp6
+	KeyKp7
+	KeyKp8
+	KeyKp9
+
+	// The following are keys defined in the Kitty keyboard protocol.
+	// TODO: Investigate the names of these keys
+	KeyKpSep
+	KeyKpUp
+	KeyKpDown
+	KeyKpLeft
+	KeyKpRight
+	KeyKpPgUp
+	KeyKpPgDown
+	KeyKpHome
+	KeyKpEnd
+	KeyKpInsert
+	KeyKpDelete
+	KeyKpBegin
+
+	// Function keys
+
 	KeyF1
 	KeyF2
 	KeyF3
@@ -255,454 +107,425 @@ const (
 	KeyF18
 	KeyF19
 	KeyF20
+	KeyF21
+	KeyF22
+	KeyF23
+	KeyF24
+	KeyF25
+	KeyF26
+	KeyF27
+	KeyF28
+	KeyF29
+	KeyF30
+	KeyF31
+	KeyF32
+	KeyF33
+	KeyF34
+	KeyF35
+	KeyF36
+	KeyF37
+	KeyF38
+	KeyF39
+	KeyF40
+	KeyF41
+	KeyF42
+	KeyF43
+	KeyF44
+	KeyF45
+	KeyF46
+	KeyF47
+	KeyF48
+	KeyF49
+	KeyF50
+	KeyF51
+	KeyF52
+	KeyF53
+	KeyF54
+	KeyF55
+	KeyF56
+	KeyF57
+	KeyF58
+	KeyF59
+	KeyF60
+	KeyF61
+	KeyF62
+	KeyF63
+
+	// The following are keys defined in the Kitty keyboard protocol.
+	// TODO: Investigate the names of these keys
+
+	KeyCapsLock
+	KeyScrollLock
+	KeyNumLock
+	KeyPrintScreen
+	KeyPause
+	KeyMenu
+
+	KeyMediaPlay
+	KeyMediaPause
+	KeyMediaPlayPause
+	KeyMediaReverse
+	KeyMediaStop
+	KeyMediaFastForward
+	KeyMediaRewind
+	KeyMediaNext
+	KeyMediaPrev
+	KeyMediaRecord
+
+	KeyLowerVol
+	KeyRaiseVol
+	KeyMute
+
+	KeyLeftShift
+	KeyLeftAlt
+	KeyLeftCtrl
+	KeyLeftSuper
+	KeyLeftHyper
+	KeyLeftMeta
+	KeyRightShift
+	KeyRightAlt
+	KeyRightCtrl
+	KeyRightSuper
+	KeyRightHyper
+	KeyRightMeta
+	KeyIsoLevel3Shift
+	KeyIsoLevel5Shift
 )
 
-// Mappings for control keys and other special keys to friendly consts.
-var keyNames = map[KeyType]string{
-	// Control keys.
-	keyNUL: "ctrl+@", // also ctrl+` (that's ctrl+backtick)
-	keySOH: "ctrl+a",
-	keySTX: "ctrl+b",
-	keyETX: "ctrl+c",
-	keyEOT: "ctrl+d",
-	keyENQ: "ctrl+e",
-	keyACK: "ctrl+f",
-	keyBEL: "ctrl+g",
-	keyBS:  "ctrl+h",
-	keyHT:  "tab", // also ctrl+i
-	keyLF:  "ctrl+j",
-	keyVT:  "ctrl+k",
-	keyFF:  "ctrl+l",
-	keyCR:  "enter",
-	keySO:  "ctrl+n",
-	keySI:  "ctrl+o",
-	keyDLE: "ctrl+p",
-	keyDC1: "ctrl+q",
-	keyDC2: "ctrl+r",
-	keyDC3: "ctrl+s",
-	keyDC4: "ctrl+t",
-	keyNAK: "ctrl+u",
-	keySYN: "ctrl+v",
-	keyETB: "ctrl+w",
-	keyCAN: "ctrl+x",
-	keyEM:  "ctrl+y",
-	keySUB: "ctrl+z",
-	keyESC: "esc",
-	keyFS:  "ctrl+\\",
-	keyGS:  "ctrl+]",
-	keyRS:  "ctrl+^",
-	keyUS:  "ctrl+_",
-	keyDEL: "backspace",
-
-	// Other keys.
-	KeyRunes:          "runes",
-	KeyUp:             "up",
-	KeyDown:           "down",
-	KeyRight:          "right",
-	KeySpace:          " ", // for backwards compatibility
-	KeyLeft:           "left",
-	KeyShiftTab:       "shift+tab",
-	KeyHome:           "home",
-	KeyEnd:            "end",
-	KeyCtrlHome:       "ctrl+home",
-	KeyCtrlEnd:        "ctrl+end",
-	KeyShiftHome:      "shift+home",
-	KeyShiftEnd:       "shift+end",
-	KeyCtrlShiftHome:  "ctrl+shift+home",
-	KeyCtrlShiftEnd:   "ctrl+shift+end",
-	KeyPgUp:           "pgup",
-	KeyPgDown:         "pgdown",
-	KeyCtrlPgUp:       "ctrl+pgup",
-	KeyCtrlPgDown:     "ctrl+pgdown",
-	KeyDelete:         "delete",
-	KeyInsert:         "insert",
-	KeyCtrlUp:         "ctrl+up",
-	KeyCtrlDown:       "ctrl+down",
-	KeyCtrlRight:      "ctrl+right",
-	KeyCtrlLeft:       "ctrl+left",
-	KeyShiftUp:        "shift+up",
-	KeyShiftDown:      "shift+down",
-	KeyShiftRight:     "shift+right",
-	KeyShiftLeft:      "shift+left",
-	KeyCtrlShiftUp:    "ctrl+shift+up",
-	KeyCtrlShiftDown:  "ctrl+shift+down",
-	KeyCtrlShiftLeft:  "ctrl+shift+left",
-	KeyCtrlShiftRight: "ctrl+shift+right",
-	KeyF1:             "f1",
-	KeyF2:             "f2",
-	KeyF3:             "f3",
-	KeyF4:             "f4",
-	KeyF5:             "f5",
-	KeyF6:             "f6",
-	KeyF7:             "f7",
-	KeyF8:             "f8",
-	KeyF9:             "f9",
-	KeyF10:            "f10",
-	KeyF11:            "f11",
-	KeyF12:            "f12",
-	KeyF13:            "f13",
-	KeyF14:            "f14",
-	KeyF15:            "f15",
-	KeyF16:            "f16",
-	KeyF17:            "f17",
-	KeyF18:            "f18",
-	KeyF19:            "f19",
-	KeyF20:            "f20",
-}
+// Key contains information about a key or release. Keys are always sent to the
+// program's update function. There are a couple general patterns you could use
+// to check for key presses or releases:
+//
+//	// Switch on the string representation of the key (shorter)
+//	switch msg := msg.(type) {
+//	case KeyPressMsg:
+//	    switch msg.String() {
+//	    case "enter":
+//	        fmt.Println("you pressed enter!")
+//	    case "a":
+//	        fmt.Println("you pressed a!")
+//	    }
+//	}
+//
+//	// Switch on the key type (more foolproof)
+//	switch msg := msg.(type) {
+//	case KeyReleaseMsg:
+//	    switch msg.Sym {
+//	    case KeyEnter:
+//	        fmt.Println("you pressed enter!")
+//	    case KeyRunes:
+//	        switch string(msg.Runes) {
+//	        case "a":
+//	            fmt.Println("you pressed a!")
+//	        }
+//	    }
+//	}
+//
+// Note that Key.Runes will always contain at least one character, so you can
+// always safely call Key.Runes[0]. In most cases Key.Runes will only contain
+// one character, though certain input method editors (most notably Chinese
+// IMEs) can input multiple runes at once.
+type Key struct {
+	// Runes contains the actual characters received. This usually has a length
+	// of 1. Use [Rune()] to get the first key rune received. If the user
+	// presses shift+a, the Runes will be `[]rune{'A'}`.
+	Runes []rune
 
-// Sequence mappings.
-var sequences = map[string]Key{
-	// Arrow keys
-	"\x1b[A":    {Type: KeyUp},
-	"\x1b[B":    {Type: KeyDown},
-	"\x1b[C":    {Type: KeyRight},
-	"\x1b[D":    {Type: KeyLeft},
-	"\x1b[1;2A": {Type: KeyShiftUp},
-	"\x1b[1;2B": {Type: KeyShiftDown},
-	"\x1b[1;2C": {Type: KeyShiftRight},
-	"\x1b[1;2D": {Type: KeyShiftLeft},
-	"\x1b[OA":   {Type: KeyShiftUp},    // DECCKM
-	"\x1b[OB":   {Type: KeyShiftDown},  // DECCKM
-	"\x1b[OC":   {Type: KeyShiftRight}, // DECCKM
-	"\x1b[OD":   {Type: KeyShiftLeft},  // DECCKM
-	"\x1b[a":    {Type: KeyShiftUp},    // urxvt
-	"\x1b[b":    {Type: KeyShiftDown},  // urxvt
-	"\x1b[c":    {Type: KeyShiftRight}, // urxvt
-	"\x1b[d":    {Type: KeyShiftLeft},  // urxvt
-	"\x1b[1;3A": {Type: KeyUp, Alt: true},
-	"\x1b[1;3B": {Type: KeyDown, Alt: true},
-	"\x1b[1;3C": {Type: KeyRight, Alt: true},
-	"\x1b[1;3D": {Type: KeyLeft, Alt: true},
-
-	"\x1b[1;4A": {Type: KeyShiftUp, Alt: true},
-	"\x1b[1;4B": {Type: KeyShiftDown, Alt: true},
-	"\x1b[1;4C": {Type: KeyShiftRight, Alt: true},
-	"\x1b[1;4D": {Type: KeyShiftLeft, Alt: true},
-
-	"\x1b[1;5A": {Type: KeyCtrlUp},
-	"\x1b[1;5B": {Type: KeyCtrlDown},
-	"\x1b[1;5C": {Type: KeyCtrlRight},
-	"\x1b[1;5D": {Type: KeyCtrlLeft},
-	"\x1b[Oa":   {Type: KeyCtrlUp, Alt: true},    // urxvt
-	"\x1b[Ob":   {Type: KeyCtrlDown, Alt: true},  // urxvt
-	"\x1b[Oc":   {Type: KeyCtrlRight, Alt: true}, // urxvt
-	"\x1b[Od":   {Type: KeyCtrlLeft, Alt: true},  // urxvt
-	"\x1b[1;6A": {Type: KeyCtrlShiftUp},
-	"\x1b[1;6B": {Type: KeyCtrlShiftDown},
-	"\x1b[1;6C": {Type: KeyCtrlShiftRight},
-	"\x1b[1;6D": {Type: KeyCtrlShiftLeft},
-	"\x1b[1;7A": {Type: KeyCtrlUp, Alt: true},
-	"\x1b[1;7B": {Type: KeyCtrlDown, Alt: true},
-	"\x1b[1;7C": {Type: KeyCtrlRight, Alt: true},
-	"\x1b[1;7D": {Type: KeyCtrlLeft, Alt: true},
-	"\x1b[1;8A": {Type: KeyCtrlShiftUp, Alt: true},
-	"\x1b[1;8B": {Type: KeyCtrlShiftDown, Alt: true},
-	"\x1b[1;8C": {Type: KeyCtrlShiftRight, Alt: true},
-	"\x1b[1;8D": {Type: KeyCtrlShiftLeft, Alt: true},
-
-	// Miscellaneous keys
-	"\x1b[Z": {Type: KeyShiftTab},
-
-	"\x1b[2~":   {Type: KeyInsert},
-	"\x1b[3;2~": {Type: KeyInsert, Alt: true},
-
-	"\x1b[3~":   {Type: KeyDelete},
-	"\x1b[3;3~": {Type: KeyDelete, Alt: true},
-
-	"\x1b[5~":   {Type: KeyPgUp},
-	"\x1b[5;3~": {Type: KeyPgUp, Alt: true},
-	"\x1b[5;5~": {Type: KeyCtrlPgUp},
-	"\x1b[5^":   {Type: KeyCtrlPgUp}, // urxvt
-	"\x1b[5;7~": {Type: KeyCtrlPgUp, Alt: true},
-
-	"\x1b[6~":   {Type: KeyPgDown},
-	"\x1b[6;3~": {Type: KeyPgDown, Alt: true},
-	"\x1b[6;5~": {Type: KeyCtrlPgDown},
-	"\x1b[6^":   {Type: KeyCtrlPgDown}, // urxvt
-	"\x1b[6;7~": {Type: KeyCtrlPgDown, Alt: true},
-
-	"\x1b[1~":   {Type: KeyHome},
-	"\x1b[H":    {Type: KeyHome},                     // xterm, lxterm
-	"\x1b[1;3H": {Type: KeyHome, Alt: true},          // xterm, lxterm
-	"\x1b[1;5H": {Type: KeyCtrlHome},                 // xterm, lxterm
-	"\x1b[1;7H": {Type: KeyCtrlHome, Alt: true},      // xterm, lxterm
-	"\x1b[1;2H": {Type: KeyShiftHome},                // xterm, lxterm
-	"\x1b[1;4H": {Type: KeyShiftHome, Alt: true},     // xterm, lxterm
-	"\x1b[1;6H": {Type: KeyCtrlShiftHome},            // xterm, lxterm
-	"\x1b[1;8H": {Type: KeyCtrlShiftHome, Alt: true}, // xterm, lxterm
-
-	"\x1b[4~":   {Type: KeyEnd},
-	"\x1b[F":    {Type: KeyEnd},                     // xterm, lxterm
-	"\x1b[1;3F": {Type: KeyEnd, Alt: true},          // xterm, lxterm
-	"\x1b[1;5F": {Type: KeyCtrlEnd},                 // xterm, lxterm
-	"\x1b[1;7F": {Type: KeyCtrlEnd, Alt: true},      // xterm, lxterm
-	"\x1b[1;2F": {Type: KeyShiftEnd},                // xterm, lxterm
-	"\x1b[1;4F": {Type: KeyShiftEnd, Alt: true},     // xterm, lxterm
-	"\x1b[1;6F": {Type: KeyCtrlShiftEnd},            // xterm, lxterm
-	"\x1b[1;8F": {Type: KeyCtrlShiftEnd, Alt: true}, // xterm, lxterm
-
-	"\x1b[7~": {Type: KeyHome},          // urxvt
-	"\x1b[7^": {Type: KeyCtrlHome},      // urxvt
-	"\x1b[7$": {Type: KeyShiftHome},     // urxvt
-	"\x1b[7@": {Type: KeyCtrlShiftHome}, // urxvt
-
-	"\x1b[8~": {Type: KeyEnd},          // urxvt
-	"\x1b[8^": {Type: KeyCtrlEnd},      // urxvt
-	"\x1b[8$": {Type: KeyShiftEnd},     // urxvt
-	"\x1b[8@": {Type: KeyCtrlShiftEnd}, // urxvt
-
-	// Function keys, Linux console
-	"\x1b[[A": {Type: KeyF1}, // linux console
-	"\x1b[[B": {Type: KeyF2}, // linux console
-	"\x1b[[C": {Type: KeyF3}, // linux console
-	"\x1b[[D": {Type: KeyF4}, // linux console
-	"\x1b[[E": {Type: KeyF5}, // linux console
-
-	// Function keys, X11
-	"\x1bOP": {Type: KeyF1}, // vt100, xterm
-	"\x1bOQ": {Type: KeyF2}, // vt100, xterm
-	"\x1bOR": {Type: KeyF3}, // vt100, xterm
-	"\x1bOS": {Type: KeyF4}, // vt100, xterm
-
-	"\x1b[1;3P": {Type: KeyF1, Alt: true}, // vt100, xterm
-	"\x1b[1;3Q": {Type: KeyF2, Alt: true}, // vt100, xterm
-	"\x1b[1;3R": {Type: KeyF3, Alt: true}, // vt100, xterm
-	"\x1b[1;3S": {Type: KeyF4, Alt: true}, // vt100, xterm
-
-	"\x1b[11~": {Type: KeyF1}, // urxvt
-	"\x1b[12~": {Type: KeyF2}, // urxvt
-	"\x1b[13~": {Type: KeyF3}, // urxvt
-	"\x1b[14~": {Type: KeyF4}, // urxvt
-
-	"\x1b[15~": {Type: KeyF5}, // vt100, xterm, also urxvt
-
-	"\x1b[15;3~": {Type: KeyF5, Alt: true}, // vt100, xterm, also urxvt
-
-	"\x1b[17~": {Type: KeyF6},  // vt100, xterm, also urxvt
-	"\x1b[18~": {Type: KeyF7},  // vt100, xterm, also urxvt
-	"\x1b[19~": {Type: KeyF8},  // vt100, xterm, also urxvt
-	"\x1b[20~": {Type: KeyF9},  // vt100, xterm, also urxvt
-	"\x1b[21~": {Type: KeyF10}, // vt100, xterm, also urxvt
-
-	"\x1b[17;3~": {Type: KeyF6, Alt: true},  // vt100, xterm
-	"\x1b[18;3~": {Type: KeyF7, Alt: true},  // vt100, xterm
-	"\x1b[19;3~": {Type: KeyF8, Alt: true},  // vt100, xterm
-	"\x1b[20;3~": {Type: KeyF9, Alt: true},  // vt100, xterm
-	"\x1b[21;3~": {Type: KeyF10, Alt: true}, // vt100, xterm
-
-	"\x1b[23~": {Type: KeyF11}, // vt100, xterm, also urxvt
-	"\x1b[24~": {Type: KeyF12}, // vt100, xterm, also urxvt
-
-	"\x1b[23;3~": {Type: KeyF11, Alt: true}, // vt100, xterm
-	"\x1b[24;3~": {Type: KeyF12, Alt: true}, // vt100, xterm
-
-	"\x1b[1;2P": {Type: KeyF13},
-	"\x1b[1;2Q": {Type: KeyF14},
-
-	"\x1b[25~": {Type: KeyF13}, // vt100, xterm, also urxvt
-	"\x1b[26~": {Type: KeyF14}, // vt100, xterm, also urxvt
-
-	"\x1b[25;3~": {Type: KeyF13, Alt: true}, // vt100, xterm
-	"\x1b[26;3~": {Type: KeyF14, Alt: true}, // vt100, xterm
-
-	"\x1b[1;2R": {Type: KeyF15},
-	"\x1b[1;2S": {Type: KeyF16},
-
-	"\x1b[28~": {Type: KeyF15}, // vt100, xterm, also urxvt
-	"\x1b[29~": {Type: KeyF16}, // vt100, xterm, also urxvt
-
-	"\x1b[28;3~": {Type: KeyF15, Alt: true}, // vt100, xterm
-	"\x1b[29;3~": {Type: KeyF16, Alt: true}, // vt100, xterm
-
-	"\x1b[15;2~": {Type: KeyF17},
-	"\x1b[17;2~": {Type: KeyF18},
-	"\x1b[18;2~": {Type: KeyF19},
-	"\x1b[19;2~": {Type: KeyF20},
-
-	"\x1b[31~": {Type: KeyF17},
-	"\x1b[32~": {Type: KeyF18},
-	"\x1b[33~": {Type: KeyF19},
-	"\x1b[34~": {Type: KeyF20},
-
-	// Powershell sequences.
-	"\x1bOA": {Type: KeyUp, Alt: false},
-	"\x1bOB": {Type: KeyDown, Alt: false},
-	"\x1bOC": {Type: KeyRight, Alt: false},
-	"\x1bOD": {Type: KeyLeft, Alt: false},
+	// Type is a special key, like enter, tab, backspace, and so on.
+	Type KeyType
+
+	// altRune is the actual, unshifted key pressed by the user. For example,
+	// if the user presses shift+a, or caps lock is on, the altRune will be
+	// 'a'.
+	//
+	// In the case of non-latin keyboards, like Arabic, altRune is the
+	// unshifted key on the keyboard.
+	//
+	// This is only available with the Kitty Keyboard Protocol or the Windows
+	// Console API.
+	altRune rune
+
+	// baseRune is the key pressed according to the standard PC-101 key layout.
+	// On internaltional keyboards, this is the key that would be pressed if
+	// the keyboard was set to US layout.
+	//
+	// For example, if the user presses 'q' on a French AZERTY keyboard, the
+	// baseRune will be 'q'.
+	//
+	// This is only available with the Kitty Keyboard Protocol or the Windows
+	// Console API.
+	baseRune rune
+
+	// Mod is a modifier key, like ctrl, alt, and so on.
+	Mod KeyMod
+
+	// IsRepeat indicates whether the key is being held down and sending events
+	// repeatedly.
+	//
+	// This is only available with the Kitty Keyboard Protocol or the Windows
+	// Console API.
+	IsRepeat bool
 }
 
-// unknownInputByteMsg is reported by the input reader when an invalid
-// utf-8 byte is detected on the input. Currently, it is not handled
-// further by bubbletea. However, having this event makes it possible
-// to troubleshoot invalid inputs.
-type unknownInputByteMsg byte
+// KeyPressMsg represents a key press message.
+type KeyPressMsg Key
 
-func (u unknownInputByteMsg) String() string {
-	return fmt.Sprintf("?%#02x?", int(u))
+// String implements fmt.Stringer and is quite useful for matching key
+// events. For details, on what this returns see [Key.String].
+func (k KeyPressMsg) String() string {
+	return Key(k).String()
 }
 
-// unknownCSISequenceMsg is reported by the input reader when an
-// unrecognized CSI sequence is detected on the input. Currently, it
-// is not handled further by bubbletea. However, having this event
-// makes it possible to troubleshoot invalid inputs.
-type unknownCSISequenceMsg []byte
-
-func (u unknownCSISequenceMsg) String() string {
-	return fmt.Sprintf("?CSI%+v?", []byte(u)[2:])
+// Rune returns the first rune in the Runes field. If the Runes field is empty,
+// it returns 0.
+func (k KeyPressMsg) Rune() rune {
+	return Key(k).Rune()
 }
 
-var spaceRunes = []rune{' '}
+// KeyReleaseMsg represents a key release message.
+type KeyReleaseMsg Key
 
-// readAnsiInputs reads keypress and mouse inputs from a TTY and produces messages
-// containing information about the key or mouse events accordingly.
-func readAnsiInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
-	var buf [256]byte
+// String implements fmt.Stringer and is quite useful for matching complex key
+// events. For details, on what this returns see [Key.String].
+func (k KeyReleaseMsg) String() string {
+	return Key(k).String()
+}
 
-	var leftOverFromPrevIteration []byte
-loop:
-	for {
-		// Read and block.
-		numBytes, err := input.Read(buf[:])
-		if err != nil {
-			return fmt.Errorf("error reading input: %w", err)
-		}
-		b := buf[:numBytes]
-		if leftOverFromPrevIteration != nil {
-			b = append(leftOverFromPrevIteration, b...)
-		}
+// Rune returns the first rune in the Runes field. If the Runes field is empty,
+// it returns 0.
+func (k KeyReleaseMsg) Rune() rune {
+	return Key(k).Rune()
+}
 
-		// If we had a short read (numBytes < len(buf)), we're sure that
-		// the end of this read is an event boundary, so there is no doubt
-		// if we are encountering the end of the buffer while parsing a message.
-		// However, if we've succeeded in filling up the buffer, there may
-		// be more data in the OS buffer ready to be read in, to complete
-		// the last message in the input. In that case, we will retry with
-		// the left over data in the next iteration.
-		canHaveMoreData := numBytes == len(buf)
-
-		var i, w int
-		for i, w = 0, 0; i < len(b); i += w {
-			var msg Msg
-			w, msg = detectOneMsg(b[i:], canHaveMoreData)
-			if w == 0 {
-				// Expecting more bytes beyond the current buffer. Try waiting
-				// for more input.
-				leftOverFromPrevIteration = make([]byte, 0, len(b[i:])+len(buf))
-				leftOverFromPrevIteration = append(leftOverFromPrevIteration, b[i:]...)
-				continue loop
-			}
-
-			select {
-			case msgs <- msg:
-			case <-ctx.Done():
-				err := ctx.Err()
-				if err != nil {
-					err = fmt.Errorf("found context error while reading input: %w", err)
-				}
-				return err
-			}
-		}
-		leftOverFromPrevIteration = nil
+// Rune returns the first rune in the Runes field. If the Runes field is empty,
+// it returns 0.
+func (k Key) Rune() rune {
+	if len(k.Runes) == 0 {
+		return 0
 	}
+	return k.Runes[0]
 }
 
-var (
-	unknownCSIRe  = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`)
-	mouseSGRRegex = regexp.MustCompile(`(\d+);(\d+);(\d+)([Mm])`)
-)
-
-func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) {
-	// Detect mouse events.
-	// X10 mouse events have a length of 6 bytes
-	const mouseEventX10Len = 6
-	if len(b) >= mouseEventX10Len && b[0] == '\x1b' && b[1] == '[' {
-		switch b[2] {
-		case 'M':
-			return mouseEventX10Len, MouseMsg(parseX10MouseEvent(b))
-		case '<':
-			if matchIndices := mouseSGRRegex.FindSubmatchIndex(b[3:]); matchIndices != nil {
-				// SGR mouse events length is the length of the match plus the length of the escape sequence
-				mouseEventSGRLen := matchIndices[1] + 3 //nolint:gomnd
-				return mouseEventSGRLen, MouseMsg(parseSGRMouseEvent(b))
-			}
-		}
+// String implements fmt.Stringer and is used to convert a key to a string.
+// While less type safe than looking at the individual fields, it will usually
+// be more convenient and readable to use this method when matching against
+// keys.
+//
+// Note that modifier keys are always printed in the following order:
+//   - ctrl
+//   - alt
+//   - shift
+//   - meta
+//   - hyper
+//   - super
+//
+// For example, you'll always see "ctrl+shift+alt+a" and never
+// "shift+ctrl+alt+a".
+func (k Key) String() string {
+	var s string
+	if k.Mod.Contains(ModCtrl) && k.Type != KeyLeftCtrl && k.Type != KeyRightCtrl {
+		s += "ctrl+"
 	}
-
-	// Detect bracketed paste.
-	var foundbp bool
-	foundbp, w, msg = detectBracketedPaste(b)
-	if foundbp {
-		return w, msg
+	if k.Mod.Contains(ModAlt) && k.Type != KeyLeftAlt && k.Type != KeyRightAlt {
+		s += "alt+"
 	}
-
-	// Detect escape sequence and control characters other than NUL,
-	// possibly with an escape character in front to mark the Alt
-	// modifier.
-	var foundSeq bool
-	foundSeq, w, msg = detectSequence(b)
-	if foundSeq {
-		return w, msg
+	if k.Mod.Contains(ModShift) && k.Type != KeyLeftShift && k.Type != KeyRightShift {
+		s += "shift+"
 	}
-
-	// No non-NUL control character or escape sequence.
-	// If we are seeing at least an escape character, remember it for later below.
-	alt := false
-	i := 0
-	if b[0] == '\x1b' {
-		alt = true
-		i++
+	if k.Mod.Contains(ModMeta) && k.Type != KeyLeftMeta && k.Type != KeyRightMeta {
+		s += "meta+"
 	}
-
-	// Are we seeing a standalone NUL? This is not handled by detectSequence().
-	if i < len(b) && b[i] == 0 {
-		return i + 1, KeyMsg{Type: keyNUL, Alt: alt}
+	if k.Mod.Contains(ModHyper) && k.Type != KeyLeftHyper && k.Type != KeyRightHyper {
+		s += "hyper+"
+	}
+	if k.Mod.Contains(ModSuper) && k.Type != KeyLeftSuper && k.Type != KeyRightSuper {
+		s += "super+"
 	}
 
-	// Find the longest sequence of runes that are not control
-	// characters from this point.
-	var runes []rune
-	for rw := 0; i < len(b); i += rw {
-		var r rune
-		r, rw = utf8.DecodeRune(b[i:])
-		if r == utf8.RuneError || r <= rune(keyUS) || r == rune(keyDEL) || r == ' ' {
-			// Rune errors are handled below; control characters and spaces will
-			// be handled by detectSequence in the next call to detectOneMsg.
-			break
+	runeStr := func(r rune) string {
+		// Space is the only invisible printable character.
+		if r == ' ' {
+			return "space"
 		}
-		runes = append(runes, r)
-		if alt {
-			// We only support a single rune after an escape alt modifier.
-			i += rw
-			break
-		}
-	}
-	if i >= len(b) && canHaveMoreData {
-		// We have encountered the end of the input buffer. Alas, we can't
-		// be sure whether the data in the remainder of the buffer is
-		// complete (maybe there was a short read). Instead of sending anything
-		// dumb to the message channel, do a short read. The outer loop will
-		// handle this case by extending the buffer as necessary.
-		return 0, nil
+		return string(r)
 	}
-
-	// If we found at least one rune, we report the bunch of them as
-	// a single KeyRunes or KeySpace event.
-	if len(runes) > 0 {
-		k := Key{Type: KeyRunes, Runes: runes, Alt: alt}
-		if len(runes) == 1 && runes[0] == ' ' {
-			k.Type = KeySpace
+	if k.baseRune != 0 {
+		// If a baseRune is present, use it to represent a key using the standard
+		// PC-101 key layout.
+		s += runeStr(k.baseRune)
+	} else if k.altRune != 0 {
+		// Otherwise, use the AltRune aka the non-shifted one if present.
+		s += runeStr(k.altRune)
+	} else if len(k.Runes) > 0 {
+		// Else, just print the rune.
+		if len(k.Runes) > 1 {
+			s += string(k.Runes)
+		} else {
+			s += runeStr(k.Rune())
 		}
-		return i, KeyMsg(k)
+	} else {
+		s += k.Type.String()
 	}
+	return s
+}
 
-	// We didn't find an escape sequence, nor a valid rune. Was this a
-	// lone escape character at the end of the input?
-	if alt && len(b) == 1 {
-		return 1, KeyMsg(Key{Type: KeyEscape})
+// String returns the string representation of the key type.
+func (k KeyType) String() string {
+	if s, ok := keyTypeString[k]; ok {
+		return s
 	}
+	return ""
+}
 
-	// The character at the current position is neither an escape
-	// sequence, a valid rune start or a sole escape character. Report
-	// it as an invalid byte.
-	return 1, unknownInputByteMsg(b[0])
+var keyTypeString = map[KeyType]string{
+	KeyRunes:      "runes",
+	KeyEnter:      "enter",
+	KeyTab:        "tab",
+	KeyBackspace:  "backspace",
+	KeyEscape:     "esc",
+	KeySpace:      "space",
+	KeyUp:         "up",
+	KeyDown:       "down",
+	KeyLeft:       "left",
+	KeyRight:      "right",
+	KeyBegin:      "begin",
+	KeyFind:       "find",
+	KeyInsert:     "insert",
+	KeyDelete:     "delete",
+	KeySelect:     "select",
+	KeyPgUp:       "pgup",
+	KeyPgDown:     "pgdown",
+	KeyHome:       "home",
+	KeyEnd:        "end",
+	KeyKpEnter:    "kpenter",
+	KeyKpEqual:    "kpequal",
+	KeyKpMultiply: "kpmul",
+	KeyKpPlus:     "kpplus",
+	KeyKpComma:    "kpcomma",
+	KeyKpMinus:    "kpminus",
+	KeyKpDecimal:  "kpperiod",
+	KeyKpDivide:   "kpdiv",
+	KeyKp0:        "kp0",
+	KeyKp1:        "kp1",
+	KeyKp2:        "kp2",
+	KeyKp3:        "kp3",
+	KeyKp4:        "kp4",
+	KeyKp5:        "kp5",
+	KeyKp6:        "kp6",
+	KeyKp7:        "kp7",
+	KeyKp8:        "kp8",
+	KeyKp9:        "kp9",
+
+	// Kitty keyboard extension
+	KeyKpSep:    "kpsep",
+	KeyKpUp:     "kpup",
+	KeyKpDown:   "kpdown",
+	KeyKpLeft:   "kpleft",
+	KeyKpRight:  "kpright",
+	KeyKpPgUp:   "kppgup",
+	KeyKpPgDown: "kppgdown",
+	KeyKpHome:   "kphome",
+	KeyKpEnd:    "kpend",
+	KeyKpInsert: "kpinsert",
+	KeyKpDelete: "kpdelete",
+	KeyKpBegin:  "kpbegin",
+
+	KeyF1:  "f1",
+	KeyF2:  "f2",
+	KeyF3:  "f3",
+	KeyF4:  "f4",
+	KeyF5:  "f5",
+	KeyF6:  "f6",
+	KeyF7:  "f7",
+	KeyF8:  "f8",
+	KeyF9:  "f9",
+	KeyF10: "f10",
+	KeyF11: "f11",
+	KeyF12: "f12",
+	KeyF13: "f13",
+	KeyF14: "f14",
+	KeyF15: "f15",
+	KeyF16: "f16",
+	KeyF17: "f17",
+	KeyF18: "f18",
+	KeyF19: "f19",
+	KeyF20: "f20",
+	KeyF21: "f21",
+	KeyF22: "f22",
+	KeyF23: "f23",
+	KeyF24: "f24",
+	KeyF25: "f25",
+	KeyF26: "f26",
+	KeyF27: "f27",
+	KeyF28: "f28",
+	KeyF29: "f29",
+	KeyF30: "f30",
+	KeyF31: "f31",
+	KeyF32: "f32",
+	KeyF33: "f33",
+	KeyF34: "f34",
+	KeyF35: "f35",
+	KeyF36: "f36",
+	KeyF37: "f37",
+	KeyF38: "f38",
+	KeyF39: "f39",
+	KeyF40: "f40",
+	KeyF41: "f41",
+	KeyF42: "f42",
+	KeyF43: "f43",
+	KeyF44: "f44",
+	KeyF45: "f45",
+	KeyF46: "f46",
+	KeyF47: "f47",
+	KeyF48: "f48",
+	KeyF49: "f49",
+	KeyF50: "f50",
+	KeyF51: "f51",
+	KeyF52: "f52",
+	KeyF53: "f53",
+	KeyF54: "f54",
+	KeyF55: "f55",
+	KeyF56: "f56",
+	KeyF57: "f57",
+	KeyF58: "f58",
+	KeyF59: "f59",
+	KeyF60: "f60",
+	KeyF61: "f61",
+	KeyF62: "f62",
+	KeyF63: "f63",
+
+	// Kitty keyboard extension
+	KeyCapsLock:         "capslock",
+	KeyScrollLock:       "scrolllock",
+	KeyNumLock:          "numlock",
+	KeyPrintScreen:      "printscreen",
+	KeyPause:            "pause",
+	KeyMenu:             "menu",
+	KeyMediaPlay:        "mediaplay",
+	KeyMediaPause:       "mediapause",
+	KeyMediaPlayPause:   "mediaplaypause",
+	KeyMediaReverse:     "mediareverse",
+	KeyMediaStop:        "mediastop",
+	KeyMediaFastForward: "mediafastforward",
+	KeyMediaRewind:      "mediarewind",
+	KeyMediaNext:        "medianext",
+	KeyMediaPrev:        "mediaprev",
+	KeyMediaRecord:      "mediarecord",
+	KeyLowerVol:         "lowervol",
+	KeyRaiseVol:         "raisevol",
+	KeyMute:             "mute",
+	KeyLeftShift:        "leftshift",
+	KeyLeftAlt:          "leftalt",
+	KeyLeftCtrl:         "leftctrl",
+	KeyLeftSuper:        "leftsuper",
+	KeyLeftHyper:        "lefthyper",
+	KeyLeftMeta:         "leftmeta",
+	KeyRightShift:       "rightshift",
+	KeyRightAlt:         "rightalt",
+	KeyRightCtrl:        "rightctrl",
+	KeyRightSuper:       "rightsuper",
+	KeyRightHyper:       "righthyper",
+	KeyRightMeta:        "rightmeta",
+	KeyIsoLevel3Shift:   "isolevel3shift",
+	KeyIsoLevel5Shift:   "isolevel5shift",
 }
diff --git a/key_deprecated.go b/key_deprecated.go
new file mode 100644
index 0000000..ab1c9de
--- /dev/null
+++ b/key_deprecated.go
@@ -0,0 +1,240 @@
+package tea
+
+import (
+	"strings"
+)
+
+// KeyMsg contains information about a keypress. KeyMsgs are always sent to
+// the program's update function. There are a couple general patterns you could
+// use to check for keypresses:
+//
+//	// Switch on the string representation of the key (shorter)
+//	switch msg := msg.(type) {
+//	case KeyMsg:
+//	    switch msg.String() {
+//	    case "enter":
+//	        fmt.Println("you pressed enter!")
+//	    case "a":
+//	        fmt.Println("you pressed a!")
+//	    }
+//	}
+//
+//	// Switch on the key type (more foolproof)
+//	switch msg := msg.(type) {
+//	case KeyMsg:
+//	    switch msg.Type {
+//	    case KeyEnter:
+//	        fmt.Println("you pressed enter!")
+//	    case KeyRunes:
+//	        switch string(msg.Runes) {
+//	        case "a":
+//	            fmt.Println("you pressed a!")
+//	        }
+//	    }
+//	}
+//
+// Note that Key.Runes will always contain at least one character, so you can
+// always safely call Key.Runes[0]. In most cases Key.Runes will only contain
+// one character, though certain input method editors (most notably Chinese
+// IMEs) can input multiple runes at once.
+//
+// Deprecated: KeyMsg is deprecated in favor of KeyPressMsg and KeyReleaseMsg.
+type KeyMsg struct {
+	Type  KeyType
+	Runes []rune
+	Alt   bool
+	Paste bool
+}
+
+// String returns a friendly string representation for a key. It's safe (and
+// encouraged) for use in key comparison.
+//
+//	k := Key{Type: KeyEnter}
+//	fmt.Println(k)
+//	// Output: enter
+func (k KeyMsg) String() (str string) {
+	var buf strings.Builder
+	if k.Alt {
+		buf.WriteString("alt+")
+	}
+	if k.Type == KeyRunes {
+		if k.Paste {
+			// Note: bubbles/keys bindings currently do string compares to
+			// recognize shortcuts. Since pasted text should never activate
+			// shortcuts, we need to ensure that the binding code doesn't
+			// match Key events that result from pastes. We achieve this
+			// here by enclosing pastes in '[...]' so that the string
+			// comparison in Matches() fails in that case.
+			buf.WriteByte('[')
+		}
+		buf.WriteString(string(k.Runes))
+		if k.Paste {
+			buf.WriteByte(']')
+		}
+		return buf.String()
+	} else if s, ok := keyNames[k.Type]; ok {
+		buf.WriteString(s)
+		return buf.String()
+	}
+	return ""
+}
+
+// Control key aliases.
+const (
+	KeyNull KeyType = -iota - 10
+	KeyBreak
+
+	KeyCtrlAt // ctrl+@
+	KeyCtrlA
+	KeyCtrlB
+	KeyCtrlC
+	KeyCtrlD
+	KeyCtrlE
+	KeyCtrlF
+	KeyCtrlG
+	KeyCtrlH
+	KeyCtrlI
+	KeyCtrlJ
+	KeyCtrlK
+	KeyCtrlL
+	KeyCtrlM
+	KeyCtrlN
+	KeyCtrlO
+	KeyCtrlP
+	KeyCtrlQ
+	KeyCtrlR
+	KeyCtrlS
+	KeyCtrlT
+	KeyCtrlU
+	KeyCtrlV
+	KeyCtrlW
+	KeyCtrlX
+	KeyCtrlY
+	KeyCtrlZ
+	KeyCtrlOpenBracket  // ctrl+[
+	KeyCtrlBackslash    // ctrl+\
+	KeyCtrlCloseBracket // ctrl+]
+	KeyCtrlCaret        // ctrl+^
+	KeyCtrlUnderscore   // ctrl+_
+	KeyCtrlQuestionMark // ctrl+?
+	KeyCtrlUp
+	KeyCtrlDown
+	KeyCtrlRight
+	KeyCtrlLeft
+	KeyCtrlPgUp
+	KeyCtrlPgDown
+	KeyCtrlHome
+	KeyCtrlEnd
+
+	KeyShiftTab
+	KeyShiftUp
+	KeyShiftDown
+	KeyShiftRight
+	KeyShiftLeft
+	KeyShiftHome
+	KeyShiftEnd
+
+	KeyCtrlShiftUp
+	KeyCtrlShiftDown
+	KeyCtrlShiftLeft
+	KeyCtrlShiftRight
+	KeyCtrlShiftHome
+	KeyCtrlShiftEnd
+
+	// Deprecated: Use KeyEscape instead.
+	KeyEsc = KeyEscape
+)
+
+// Mappings for control keys and other special keys to friendly consts.
+var keyNames = map[KeyType]string{
+	// Control keys.
+	KeyCtrlAt:           "ctrl+@", // also ctrl+` (that's ctrl+backtick)
+	KeyCtrlA:            "ctrl+a",
+	KeyCtrlB:            "ctrl+b",
+	KeyCtrlC:            "ctrl+c",
+	KeyCtrlD:            "ctrl+d",
+	KeyCtrlE:            "ctrl+e",
+	KeyCtrlF:            "ctrl+f",
+	KeyCtrlG:            "ctrl+g",
+	KeyCtrlH:            "ctrl+h",
+	KeyTab:              "tab", // also ctrl+i
+	KeyCtrlJ:            "ctrl+j",
+	KeyCtrlK:            "ctrl+k",
+	KeyCtrlL:            "ctrl+l",
+	KeyEnter:            "enter",
+	KeyCtrlN:            "ctrl+n",
+	KeyCtrlO:            "ctrl+o",
+	KeyCtrlP:            "ctrl+p",
+	KeyCtrlQ:            "ctrl+q",
+	KeyCtrlR:            "ctrl+r",
+	KeyCtrlS:            "ctrl+s",
+	KeyCtrlT:            "ctrl+t",
+	KeyCtrlU:            "ctrl+u",
+	KeyCtrlV:            "ctrl+v",
+	KeyCtrlW:            "ctrl+w",
+	KeyCtrlX:            "ctrl+x",
+	KeyCtrlY:            "ctrl+y",
+	KeyCtrlZ:            "ctrl+z",
+	KeyEscape:           "esc",
+	KeyCtrlOpenBracket:  "ctrl+[",
+	KeyCtrlBackslash:    "ctrl+\\",
+	KeyCtrlCloseBracket: "ctrl+]",
+	KeyCtrlCaret:        "ctrl+^",
+	KeyCtrlUnderscore:   "ctrl+_",
+	KeyBackspace:        "backspace",
+
+	// Other keys.
+	KeyRunes:          "runes",
+	KeyUp:             "up",
+	KeyDown:           "down",
+	KeyRight:          "right",
+	KeySpace:          " ", // for backwards compatibility
+	KeyLeft:           "left",
+	KeyShiftTab:       "shift+tab",
+	KeyHome:           "home",
+	KeyEnd:            "end",
+	KeyCtrlHome:       "ctrl+home",
+	KeyCtrlEnd:        "ctrl+end",
+	KeyShiftHome:      "shift+home",
+	KeyShiftEnd:       "shift+end",
+	KeyCtrlShiftHome:  "ctrl+shift+home",
+	KeyCtrlShiftEnd:   "ctrl+shift+end",
+	KeyPgUp:           "pgup",
+	KeyPgDown:         "pgdown",
+	KeyCtrlPgUp:       "ctrl+pgup",
+	KeyCtrlPgDown:     "ctrl+pgdown",
+	KeyDelete:         "delete",
+	KeyInsert:         "insert",
+	KeyCtrlUp:         "ctrl+up",
+	KeyCtrlDown:       "ctrl+down",
+	KeyCtrlRight:      "ctrl+right",
+	KeyCtrlLeft:       "ctrl+left",
+	KeyShiftUp:        "shift+up",
+	KeyShiftDown:      "shift+down",
+	KeyShiftRight:     "shift+right",
+	KeyShiftLeft:      "shift+left",
+	KeyCtrlShiftUp:    "ctrl+shift+up",
+	KeyCtrlShiftDown:  "ctrl+shift+down",
+	KeyCtrlShiftLeft:  "ctrl+shift+left",
+	KeyCtrlShiftRight: "ctrl+shift+right",
+	KeyF1:             "f1",
+	KeyF2:             "f2",
+	KeyF3:             "f3",
+	KeyF4:             "f4",
+	KeyF5:             "f5",
+	KeyF6:             "f6",
+	KeyF7:             "f7",
+	KeyF8:             "f8",
+	KeyF9:             "f9",
+	KeyF10:            "f10",
+	KeyF11:            "f11",
+	KeyF12:            "f12",
+	KeyF13:            "f13",
+	KeyF14:            "f14",
+	KeyF15:            "f15",
+	KeyF16:            "f16",
+	KeyF17:            "f17",
+	KeyF18:            "f18",
+	KeyF19:            "f19",
+	KeyF20:            "f20",
+}
diff --git a/key_other.go b/key_other.go
deleted file mode 100644
index b8c4608..0000000
--- a/key_other.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//go:build !windows
-// +build !windows
-
-package tea
-
-import (
-	"context"
-	"io"
-)
-
-func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
-	return readAnsiInputs(ctx, msgs, input)
-}
diff --git a/key_sequences.go b/key_sequences.go
deleted file mode 100644
index 4ba0f79..0000000
--- a/key_sequences.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package tea
-
-import (
-	"bytes"
-	"sort"
-	"unicode/utf8"
-)
-
-// extSequences is used by the map-based algorithm below. It contains
-// the sequences plus their alternatives with an escape character
-// prefixed, plus the control chars, plus the space.
-// It does not contain the NUL character, which is handled specially
-// by detectOneMsg.
-var extSequences = func() map[string]Key {
-	s := map[string]Key{}
-	for seq, key := range sequences {
-		key := key
-		s[seq] = key
-		if !key.Alt {
-			key.Alt = true
-			s["\x1b"+seq] = key
-		}
-	}
-	for i := keyNUL + 1; i <= keyDEL; i++ {
-		if i == keyESC {
-			continue
-		}
-		s[string([]byte{byte(i)})] = Key{Type: i}
-		s[string([]byte{'\x1b', byte(i)})] = Key{Type: i, Alt: true}
-		if i == keyUS {
-			i = keyDEL - 1
-		}
-	}
-	s[" "] = Key{Type: KeySpace, Runes: spaceRunes}
-	s["\x1b "] = Key{Type: KeySpace, Alt: true, Runes: spaceRunes}
-	s["\x1b\x1b"] = Key{Type: KeyEscape, Alt: true}
-	return s
-}()
-
-// seqLengths is the sizes of valid sequences, starting with the
-// largest size.
-var seqLengths = func() []int {
-	sizes := map[int]struct{}{}
-	for seq := range extSequences {
-		sizes[len(seq)] = struct{}{}
-	}
-	lsizes := make([]int, 0, len(sizes))
-	for sz := range sizes {
-		lsizes = append(lsizes, sz)
-	}
-	sort.Slice(lsizes, func(i, j int) bool { return lsizes[i] > lsizes[j] })
-	return lsizes
-}()
-
-// detectSequence uses a longest prefix match over the input
-// sequence and a hash map.
-func detectSequence(input []byte) (hasSeq bool, width int, msg Msg) {
-	seqs := extSequences
-	for _, sz := range seqLengths {
-		if sz > len(input) {
-			continue
-		}
-		prefix := input[:sz]
-		key, ok := seqs[string(prefix)]
-		if ok {
-			return true, sz, KeyMsg(key)
-		}
-	}
-	// Is this an unknown CSI sequence?
-	if loc := unknownCSIRe.FindIndex(input); loc != nil {
-		return true, loc[1], unknownCSISequenceMsg(input[:loc[1]])
-	}
-
-	return false, 0, nil
-}
-
-// detectBracketedPaste detects an input pasted while bracketed
-// paste mode was enabled.
-//
-// Note: this function is a no-op if bracketed paste was not enabled
-// on the terminal, since in that case we'd never see this
-// particular escape sequence.
-func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) {
-	// Detect the start sequence.
-	const bpStart = "\x1b[200~"
-	if len(input) < len(bpStart) || string(input[:len(bpStart)]) != bpStart {
-		return false, 0, nil
-	}
-
-	// Skip over the start sequence.
-	input = input[len(bpStart):]
-
-	// If we saw the start sequence, then we must have an end sequence
-	// as well. Find it.
-	const bpEnd = "\x1b[201~"
-	idx := bytes.Index(input, []byte(bpEnd))
-	inputLen := len(bpStart) + idx + len(bpEnd)
-	if idx == -1 {
-		// We have encountered the end of the input buffer without seeing
-		// the marker for the end of the bracketed paste.
-		// Tell the outer loop we have done a short read and we want more.
-		return true, 0, nil
-	}
-
-	// The paste is everything in-between.
-	paste := input[:idx]
-
-	// All there is in-between is runes, not to be interpreted further.
-	k := Key{Type: KeyRunes, Paste: true}
-	for len(paste) > 0 {
-		r, w := utf8.DecodeRune(paste)
-		if r != utf8.RuneError {
-			k.Runes = append(k.Runes, r)
-		}
-		paste = paste[w:]
-	}
-
-	return true, inputLen, KeyMsg(k)
-}
diff --git a/key_windows.go b/key_windows.go
deleted file mode 100644
index b693efd..0000000
--- a/key_windows.go
+++ /dev/null
@@ -1,351 +0,0 @@
-//go:build windows
-// +build windows
-
-package tea
-
-import (
-	"context"
-	"fmt"
-	"io"
-
-	"github.com/erikgeiser/coninput"
-	localereader "github.com/mattn/go-localereader"
-	"golang.org/x/sys/windows"
-)
-
-func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
-	if coninReader, ok := input.(*conInputReader); ok {
-		return readConInputs(ctx, msgs, coninReader.conin)
-	}
-
-	return readAnsiInputs(ctx, msgs, localereader.NewReader(input))
-}
-
-func readConInputs(ctx context.Context, msgsch chan<- Msg, con windows.Handle) error {
-	var ps coninput.ButtonState                 // keep track of previous mouse state
-	var ws coninput.WindowBufferSizeEventRecord // keep track of the last window size event
-	for {
-		events, err := coninput.ReadNConsoleInputs(con, 16)
-		if err != nil {
-			return fmt.Errorf("read coninput events: %w", err)
-		}
-
-		for _, event := range events {
-			var msgs []Msg
-			switch e := event.Unwrap().(type) {
-			case coninput.KeyEventRecord:
-				if !e.KeyDown || e.VirtualKeyCode == coninput.VK_SHIFT {
-					continue
-				}
-
-				for i := 0; i < int(e.RepeatCount); i++ {
-					eventKeyType := keyType(e)
-					var runes []rune
-
-					// Add the character only if the key type is an actual character and not a control sequence.
-					// This mimics the behavior in readAnsiInputs where the character is also removed.
-					// We don't need to handle KeySpace here. See the comment in keyType().
-					if eventKeyType == KeyRunes {
-						runes = []rune{e.Char}
-					}
-
-					msgs = append(msgs, KeyMsg{
-						Type:  eventKeyType,
-						Runes: runes,
-						Alt:   e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED),
-					})
-				}
-			case coninput.WindowBufferSizeEventRecord:
-				if e != ws {
-					ws = e
-					msgs = append(msgs, WindowSizeMsg{
-						Width:  int(e.Size.X),
-						Height: int(e.Size.Y),
-					})
-				}
-			case coninput.MouseEventRecord:
-				event := mouseEvent(ps, e)
-				if event.Type != MouseUnknown {
-					msgs = append(msgs, event)
-				}
-				ps = e.ButtonState
-			case coninput.FocusEventRecord, coninput.MenuEventRecord:
-				// ignore
-			default: // unknown event
-				continue
-			}
-
-			// Send all messages to the channel
-			for _, msg := range msgs {
-				select {
-				case msgsch <- msg:
-				case <-ctx.Done():
-					err := ctx.Err()
-					if err != nil {
-						return fmt.Errorf("coninput context error: %w", err)
-					}
-					return err
-				}
-			}
-		}
-	}
-}
-
-func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, action MouseAction) {
-	btn := p ^ s
-	action = MouseActionPress
-	if btn&s == 0 {
-		action = MouseActionRelease
-	}
-
-	if btn == 0 {
-		switch {
-		case s&coninput.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
-			button = MouseButtonLeft
-		case s&coninput.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
-			button = MouseButtonMiddle
-		case s&coninput.RIGHTMOST_BUTTON_PRESSED > 0:
-			button = MouseButtonRight
-		case s&coninput.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
-			button = MouseButtonBackward
-		case s&coninput.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
-			button = MouseButtonForward
-		}
-		return
-	}
-
-	switch {
-	case btn == coninput.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
-		button = MouseButtonLeft
-	case btn == coninput.RIGHTMOST_BUTTON_PRESSED: // right button
-		button = MouseButtonRight
-	case btn == coninput.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
-		button = MouseButtonMiddle
-	case btn == coninput.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
-		button = MouseButtonBackward
-	case btn == coninput.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
-		button = MouseButtonForward
-	}
-
-	return button, action
-}
-
-func mouseEvent(p coninput.ButtonState, e coninput.MouseEventRecord) MouseMsg {
-	ev := MouseMsg{
-		X:     int(e.MousePositon.X),
-		Y:     int(e.MousePositon.Y),
-		Alt:   e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED),
-		Ctrl:  e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED),
-		Shift: e.ControlKeyState.Contains(coninput.SHIFT_PRESSED),
-	}
-	switch e.EventFlags {
-	case coninput.CLICK, coninput.DOUBLE_CLICK:
-		ev.Button, ev.Action = mouseEventButton(p, e.ButtonState)
-		if ev.Action == MouseActionRelease {
-			ev.Type = MouseRelease
-		}
-		switch ev.Button {
-		case MouseButtonLeft:
-			ev.Type = MouseLeft
-		case MouseButtonMiddle:
-			ev.Type = MouseMiddle
-		case MouseButtonRight:
-			ev.Type = MouseRight
-		case MouseButtonBackward:
-			ev.Type = MouseBackward
-		case MouseButtonForward:
-			ev.Type = MouseForward
-		}
-	case coninput.MOUSE_WHEELED:
-		if e.WheelDirection > 0 {
-			ev.Button = MouseButtonWheelUp
-			ev.Type = MouseWheelUp
-		} else {
-			ev.Button = MouseButtonWheelDown
-			ev.Type = MouseWheelDown
-		}
-	case coninput.MOUSE_HWHEELED:
-		if e.WheelDirection > 0 {
-			ev.Button = MouseButtonWheelRight
-			ev.Type = MouseWheelRight
-		} else {
-			ev.Button = MouseButtonWheelLeft
-			ev.Type = MouseWheelLeft
-		}
-	case coninput.MOUSE_MOVED:
-		ev.Button, _ = mouseEventButton(p, e.ButtonState)
-		ev.Action = MouseActionMotion
-		ev.Type = MouseMotion
-	}
-
-	return ev
-}
-
-func keyType(e coninput.KeyEventRecord) KeyType {
-	code := e.VirtualKeyCode
-
-	shiftPressed := e.ControlKeyState.Contains(coninput.SHIFT_PRESSED)
-	ctrlPressed := e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED)
-
-	switch code {
-	case coninput.VK_RETURN:
-		return KeyEnter
-	case coninput.VK_BACK:
-		return KeyBackspace
-	case coninput.VK_TAB:
-		if shiftPressed {
-			return KeyShiftTab
-		}
-		return KeyTab
-	case coninput.VK_SPACE:
-		return KeyRunes // this could be KeySpace but on unix space also produces KeyRunes
-	case coninput.VK_ESCAPE:
-		return KeyEscape
-	case coninput.VK_UP:
-		switch {
-		case shiftPressed && ctrlPressed:
-			return KeyCtrlShiftUp
-		case shiftPressed:
-			return KeyShiftUp
-		case ctrlPressed:
-			return KeyCtrlUp
-		default:
-			return KeyUp
-		}
-	case coninput.VK_DOWN:
-		switch {
-		case shiftPressed && ctrlPressed:
-			return KeyCtrlShiftDown
-		case shiftPressed:
-			return KeyShiftDown
-		case ctrlPressed:
-			return KeyCtrlDown
-		default:
-			return KeyDown
-		}
-	case coninput.VK_RIGHT:
-		switch {
-		case shiftPressed && ctrlPressed:
-			return KeyCtrlShiftRight
-		case shiftPressed:
-			return KeyShiftRight
-		case ctrlPressed:
-			return KeyCtrlRight
-		default:
-			return KeyRight
-		}
-	case coninput.VK_LEFT:
-		switch {
-		case shiftPressed && ctrlPressed:
-			return KeyCtrlShiftLeft
-		case shiftPressed:
-			return KeyShiftLeft
-		case ctrlPressed:
-			return KeyCtrlLeft
-		default:
-			return KeyLeft
-		}
-	case coninput.VK_HOME:
-		switch {
-		case shiftPressed && ctrlPressed:
-			return KeyCtrlShiftHome
-		case shiftPressed:
-			return KeyShiftHome
-		case ctrlPressed:
-			return KeyCtrlHome
-		default:
-			return KeyHome
-		}
-	case coninput.VK_END:
-		switch {
-		case shiftPressed && ctrlPressed:
-			return KeyCtrlShiftEnd
-		case shiftPressed:
-			return KeyShiftEnd
-		case ctrlPressed:
-			return KeyCtrlEnd
-		default:
-			return KeyEnd
-		}
-	case coninput.VK_PRIOR:
-		return KeyPgUp
-	case coninput.VK_NEXT:
-		return KeyPgDown
-	case coninput.VK_DELETE:
-		return KeyDelete
-	default:
-		if e.ControlKeyState&(coninput.LEFT_CTRL_PRESSED|coninput.RIGHT_CTRL_PRESSED) == 0 {
-			return KeyRunes
-		}
-
-		switch e.Char {
-		case '@':
-			return KeyCtrlAt
-		case '\x01':
-			return KeyCtrlA
-		case '\x02':
-			return KeyCtrlB
-		case '\x03':
-			return KeyCtrlC
-		case '\x04':
-			return KeyCtrlD
-		case '\x05':
-			return KeyCtrlE
-		case '\x06':
-			return KeyCtrlF
-		case '\a':
-			return KeyCtrlG
-		case '\b':
-			return KeyCtrlH
-		case '\t':
-			return KeyCtrlI
-		case '\n':
-			return KeyCtrlJ
-		case '\v':
-			return KeyCtrlK
-		case '\f':
-			return KeyCtrlL
-		case '\r':
-			return KeyCtrlM
-		case '\x0e':
-			return KeyCtrlN
-		case '\x0f':
-			return KeyCtrlO
-		case '\x10':
-			return KeyCtrlP
-		case '\x11':
-			return KeyCtrlQ
-		case '\x12':
-			return KeyCtrlR
-		case '\x13':
-			return KeyCtrlS
-		case '\x14':
-			return KeyCtrlT
-		case '\x15':
-			return KeyCtrlU
-		case '\x16':
-			return KeyCtrlV
-		case '\x17':
-			return KeyCtrlW
-		case '\x18':
-			return KeyCtrlX
-		case '\x19':
-			return KeyCtrlY
-		case '\x1a':
-			return KeyCtrlZ
-		case '\x1b':
-			return KeyCtrlCloseBracket
-		case '\x1c':
-			return KeyCtrlBackslash
-		case '\x1f':
-			return KeyCtrlUnderscore
-		}
-
-		switch code {
-		case coninput.VK_OEM_4:
-			return KeyCtrlOpenBracket
-		}
-
-		return KeyRunes
-	}
-}
diff --git a/kitty.go b/kitty.go
new file mode 100644
index 0000000..ff6279d
--- /dev/null
+++ b/kitty.go
@@ -0,0 +1,282 @@
+package tea
+
+import (
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/charmbracelet/x/ansi"
+)
+
+// KittyKeyboardMsg represents Kitty keyboard progressive enhancement flags message.
+type KittyKeyboardMsg int
+
+// IsDisambiguateEscapeCodes returns true if the DisambiguateEscapeCodes flag is set.
+func (e KittyKeyboardMsg) IsDisambiguateEscapeCodes() bool {
+	return e&ansi.KittyDisambiguateEscapeCodes != 0
+}
+
+// IsReportEventTypes returns true if the ReportEventTypes flag is set.
+func (e KittyKeyboardMsg) IsReportEventTypes() bool {
+	return e&ansi.KittyReportEventTypes != 0
+}
+
+// IsReportAlternateKeys returns true if the ReportAlternateKeys flag is set.
+func (e KittyKeyboardMsg) IsReportAlternateKeys() bool {
+	return e&ansi.KittyReportAlternateKeys != 0
+}
+
+// IsReportAllKeys returns true if the ReportAllKeys flag is set.
+func (e KittyKeyboardMsg) IsReportAllKeys() bool {
+	return e&ansi.KittyReportAllKeys != 0
+}
+
+// IsReportAssociatedKeys returns true if the ReportAssociatedKeys flag is set.
+func (e KittyKeyboardMsg) IsReportAssociatedKeys() bool {
+	return e&ansi.KittyReportAssociatedKeys != 0
+}
+
+// Kitty Clipboard Control Sequences
+var kittyKeyMap = map[int]KeyType{
+	ansi.BS:  KeyBackspace,
+	ansi.HT:  KeyTab,
+	ansi.CR:  KeyEnter,
+	ansi.ESC: KeyEscape,
+	ansi.DEL: KeyBackspace,
+
+	57344: KeyEscape,
+	57345: KeyEnter,
+	57346: KeyTab,
+	57347: KeyBackspace,
+	57348: KeyInsert,
+	57349: KeyDelete,
+	57350: KeyLeft,
+	57351: KeyRight,
+	57352: KeyUp,
+	57353: KeyDown,
+	57354: KeyPgUp,
+	57355: KeyPgDown,
+	57356: KeyHome,
+	57357: KeyEnd,
+	57358: KeyCapsLock,
+	57359: KeyScrollLock,
+	57360: KeyNumLock,
+	57361: KeyPrintScreen,
+	57362: KeyPause,
+	57363: KeyMenu,
+	57364: KeyF1,
+	57365: KeyF2,
+	57366: KeyF3,
+	57367: KeyF4,
+	57368: KeyF5,
+	57369: KeyF6,
+	57370: KeyF7,
+	57371: KeyF8,
+	57372: KeyF9,
+	57373: KeyF10,
+	57374: KeyF11,
+	57375: KeyF12,
+	57376: KeyF13,
+	57377: KeyF14,
+	57378: KeyF15,
+	57379: KeyF16,
+	57380: KeyF17,
+	57381: KeyF18,
+	57382: KeyF19,
+	57383: KeyF20,
+	57384: KeyF21,
+	57385: KeyF22,
+	57386: KeyF23,
+	57387: KeyF24,
+	57388: KeyF25,
+	57389: KeyF26,
+	57390: KeyF27,
+	57391: KeyF28,
+	57392: KeyF29,
+	57393: KeyF30,
+	57394: KeyF31,
+	57395: KeyF32,
+	57396: KeyF33,
+	57397: KeyF34,
+	57398: KeyF35,
+	57399: KeyKp0,
+	57400: KeyKp1,
+	57401: KeyKp2,
+	57402: KeyKp3,
+	57403: KeyKp4,
+	57404: KeyKp5,
+	57405: KeyKp6,
+	57406: KeyKp7,
+	57407: KeyKp8,
+	57408: KeyKp9,
+	57409: KeyKpDecimal,
+	57410: KeyKpDivide,
+	57411: KeyKpMultiply,
+	57412: KeyKpMinus,
+	57413: KeyKpPlus,
+	57414: KeyKpEnter,
+	57415: KeyKpEqual,
+	57416: KeyKpSep,
+	57417: KeyKpLeft,
+	57418: KeyKpRight,
+	57419: KeyKpUp,
+	57420: KeyKpDown,
+	57421: KeyKpPgUp,
+	57422: KeyKpPgDown,
+	57423: KeyKpHome,
+	57424: KeyKpEnd,
+	57425: KeyKpInsert,
+	57426: KeyKpDelete,
+	57427: KeyKpBegin,
+	57428: KeyMediaPlay,
+	57429: KeyMediaPause,
+	57430: KeyMediaPlayPause,
+	57431: KeyMediaReverse,
+	57432: KeyMediaStop,
+	57433: KeyMediaFastForward,
+	57434: KeyMediaRewind,
+	57435: KeyMediaNext,
+	57436: KeyMediaPrev,
+	57437: KeyMediaRecord,
+	57438: KeyLowerVol,
+	57439: KeyRaiseVol,
+	57440: KeyMute,
+	57441: KeyLeftShift,
+	57442: KeyLeftCtrl,
+	57443: KeyLeftAlt,
+	57444: KeyLeftSuper,
+	57445: KeyLeftHyper,
+	57446: KeyLeftMeta,
+	57447: KeyRightShift,
+	57448: KeyRightCtrl,
+	57449: KeyRightAlt,
+	57450: KeyRightSuper,
+	57451: KeyRightHyper,
+	57452: KeyRightMeta,
+	57453: KeyIsoLevel3Shift,
+	57454: KeyIsoLevel5Shift,
+}
+
+const (
+	kittyShift = 1 << iota
+	kittyAlt
+	kittyCtrl
+	kittySuper
+	kittyHyper
+	kittyMeta
+	kittyCapsLock
+	kittyNumLock
+)
+
+func fromKittyMod(mod int) KeyMod {
+	var m KeyMod
+	if mod&kittyShift != 0 {
+		m |= ModShift
+	}
+	if mod&kittyAlt != 0 {
+		m |= ModAlt
+	}
+	if mod&kittyCtrl != 0 {
+		m |= ModCtrl
+	}
+	if mod&kittySuper != 0 {
+		m |= ModSuper
+	}
+	if mod&kittyHyper != 0 {
+		m |= ModHyper
+	}
+	if mod&kittyMeta != 0 {
+		m |= ModMeta
+	}
+	if mod&kittyCapsLock != 0 {
+		m |= ModCapsLock
+	}
+	if mod&kittyNumLock != 0 {
+		m |= ModNumLock
+	}
+	return m
+}
+
+// parseKittyKeyboard parses a Kitty Keyboard Protocol sequence.
+//
+// In `CSI u`, this is parsed as:
+//
+//	CSI codepoint ; modifiers u
+//	codepoint: ASCII Dec value
+//
+// The Kitty Keyboard Protocol extends this with optional components that can be
+// enabled progressively. The full sequence is parsed as:
+//
+//	CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u
+//
+// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/
+func parseKittyKeyboard(csi *ansi.CsiSequence) Msg {
+	var isRelease bool
+	key := Key{}
+
+	if params := csi.Subparams(0); len(params) > 0 {
+		code := params[0]
+		if sym, ok := kittyKeyMap[code]; ok {
+			key.Type = sym
+		} else {
+			r := rune(code)
+			if !utf8.ValidRune(r) {
+				r = utf8.RuneError
+			}
+
+			key.Type = KeyRunes
+			key.Runes = []rune{r}
+
+			// alternate key reporting
+			switch len(params) {
+			case 3:
+				// shifted key + base key
+				if b := rune(params[2]); unicode.IsPrint(b) {
+					// XXX: When alternate key reporting is enabled, the protocol
+					// can return 3 things, the unicode codepoint of the key,
+					// the shifted codepoint of the key, and the standard
+					// PC-101 key layout codepoint.
+					// This is useful to create an unambiguous mapping of keys
+					// when using a different language layout.
+					key.baseRune = b
+				}
+				fallthrough
+			case 2:
+				// shifted key
+				if s := rune(params[1]); unicode.IsPrint(s) {
+					// XXX: We swap keys here because we want the shifted key
+					// to be the Rune that is returned by the event.
+					// For example, shift+a should produce "A" not "a".
+					// In such a case, we set AltRune to the original key "a"
+					// and Rune to "A".
+					key.altRune = key.Rune()
+					key.Runes = []rune{s}
+				}
+			}
+		}
+	}
+	if params := csi.Subparams(1); len(params) > 0 {
+		mod := params[0]
+		if mod > 1 {
+			key.Mod = fromKittyMod(mod - 1)
+		}
+		if len(params) > 1 {
+			switch params[1] {
+			case 2:
+				key.IsRepeat = true
+			case 3:
+				isRelease = true
+			}
+		}
+	}
+	// TODO: Associated keys are not support yet.
+	// if params := csi.Subparams(2); len(params) > 0 {
+	// 	r := rune(params[0])
+	// 	if unicode.IsPrint(r) {
+	// 		key.AltRune = r
+	// 	}
+	// }
+	if isRelease {
+		return KeyReleaseMsg(key)
+	}
+	return KeyPressMsg(key)
+}
diff --git a/mod.go b/mod.go
new file mode 100644
index 0000000..9bb43a0
--- /dev/null
+++ b/mod.go
@@ -0,0 +1,37 @@
+package tea
+
+// KeyMod represents modifier keys.
+type KeyMod uint16
+
+// Modifier keys.
+const (
+	ModShift KeyMod = 1 << iota
+	ModAlt
+	ModCtrl
+	ModMeta
+
+	// These modifiers are used with the Kitty protocol.
+	// XXX: Meta and Super are swapped in the Kitty protocol,
+	// this is to preserve compatibility with XTerm modifiers.
+
+	ModHyper
+	ModSuper // Windows/Command keys
+
+	// These are key lock states.
+
+	ModCapsLock
+	ModNumLock
+	ModScrollLock // Defined in Windows API only
+)
+
+// Contains reports whether m contains the given modifiers.
+//
+// Example:
+//
+//	m := ModAlt | ModCtrl
+//	m.Contains(ModCtrl) // true
+//	m.Contains(ModAlt | ModCtrl) // true
+//	m.Contains(ModAlt | ModCtrl | ModShift) // false
+func (m KeyMod) Contains(mods KeyMod) bool {
+	return m&mods == mods
+}
diff --git a/mode.go b/mode.go
new file mode 100644
index 0000000..5dfcbd0
--- /dev/null
+++ b/mode.go
@@ -0,0 +1,12 @@
+package tea
+
+// ReportModeMsg is a message that represents a mode report event (DECRPM).
+//
+// See: https://vt100.net/docs/vt510-rm/DECRPM.html
+type ReportModeMsg struct {
+	// Mode is the mode number.
+	Mode int
+
+	// Value is the mode value.
+	Value int
+}
diff --git a/mouse.go b/mouse.go
index 6ec51cc..f3460fa 100644
--- a/mouse.go
+++ b/mouse.go
@@ -1,89 +1,10 @@
 package tea
 
-import "strconv"
-
-// MouseMsg contains information about a mouse event and are sent to a programs
-// update function when mouse activity occurs. Note that the mouse must first
-// be enabled in order for the mouse events to be received.
-type MouseMsg MouseEvent
-
-// String returns a string representation of a mouse event.
-func (m MouseMsg) String() string {
-	return MouseEvent(m).String()
-}
-
-// MouseEvent represents a mouse event, which could be a click, a scroll wheel
-// movement, a cursor movement, or a combination.
-type MouseEvent struct {
-	X      int
-	Y      int
-	Shift  bool
-	Alt    bool
-	Ctrl   bool
-	Action MouseAction
-	Button MouseButton
-
-	// Deprecated: Use MouseAction & MouseButton instead.
-	Type MouseEventType
-}
-
-// IsWheel returns true if the mouse event is a wheel event.
-func (m MouseEvent) IsWheel() bool {
-	return m.Button == MouseButtonWheelUp || m.Button == MouseButtonWheelDown ||
-		m.Button == MouseButtonWheelLeft || m.Button == MouseButtonWheelRight
-}
-
-// String returns a string representation of a mouse event.
-func (m MouseEvent) String() (s string) {
-	if m.Ctrl {
-		s += "ctrl+"
-	}
-	if m.Alt {
-		s += "alt+"
-	}
-	if m.Shift {
-		s += "shift+"
-	}
-
-	if m.Button == MouseButtonNone { //nolint:nestif
-		if m.Action == MouseActionMotion || m.Action == MouseActionRelease {
-			s += mouseActions[m.Action]
-		} else {
-			s += "unknown"
-		}
-	} else if m.IsWheel() {
-		s += mouseButtons[m.Button]
-	} else {
-		btn := mouseButtons[m.Button]
-		if btn != "" {
-			s += btn
-		}
-		act := mouseActions[m.Action]
-		if act != "" {
-			s += " " + act
-		}
-	}
-
-	return s
-}
-
-// MouseAction represents the action that occurred during a mouse event.
-type MouseAction int
-
-// Mouse event actions.
-const (
-	MouseActionPress MouseAction = iota
-	MouseActionRelease
-	MouseActionMotion
+import (
+	"github.com/charmbracelet/x/ansi"
 )
 
-var mouseActions = map[MouseAction]string{
-	MouseActionPress:   "press",
-	MouseActionRelease: "release",
-	MouseActionMotion:  "motion",
-}
-
-// MouseButton represents the button that was pressed during a mouse event.
+// MouseButton represents the button that was pressed during a mouse message.
 type MouseButton int
 
 // Mouse event buttons
@@ -104,58 +25,100 @@ type MouseButton int
 //
 // Other buttons are not supported.
 const (
-	MouseButtonNone MouseButton = iota
-	MouseButtonLeft
-	MouseButtonMiddle
-	MouseButtonRight
-	MouseButtonWheelUp
-	MouseButtonWheelDown
-	MouseButtonWheelLeft
-	MouseButtonWheelRight
-	MouseButtonBackward
-	MouseButtonForward
-	MouseButton10
-	MouseButton11
-)
-
-var mouseButtons = map[MouseButton]string{
-	MouseButtonNone:       "none",
-	MouseButtonLeft:       "left",
-	MouseButtonMiddle:     "middle",
-	MouseButtonRight:      "right",
-	MouseButtonWheelUp:    "wheel up",
-	MouseButtonWheelDown:  "wheel down",
-	MouseButtonWheelLeft:  "wheel left",
-	MouseButtonWheelRight: "wheel right",
-	MouseButtonBackward:   "backward",
-	MouseButtonForward:    "forward",
-	MouseButton10:         "button 10",
-	MouseButton11:         "button 11",
-}
-
-// MouseEventType indicates the type of mouse event occurring.
-//
-// Deprecated: Use MouseAction & MouseButton instead.
-type MouseEventType int
-
-// Mouse event types.
-//
-// Deprecated: Use MouseAction & MouseButton instead.
-const (
-	MouseUnknown MouseEventType = iota
+	MouseNone MouseButton = iota
 	MouseLeft
-	MouseRight
 	MouseMiddle
-	MouseRelease // mouse button release (X10 only)
+	MouseRight
 	MouseWheelUp
 	MouseWheelDown
 	MouseWheelLeft
 	MouseWheelRight
 	MouseBackward
 	MouseForward
-	MouseMotion
+	MouseExtra1
+	MouseExtra2
 )
 
+var mouseButtons = map[MouseButton]string{
+	MouseNone:       "none",
+	MouseLeft:       "left",
+	MouseMiddle:     "middle",
+	MouseRight:      "right",
+	MouseWheelUp:    "wheelup",
+	MouseWheelDown:  "wheeldown",
+	MouseWheelLeft:  "wheelleft",
+	MouseWheelRight: "wheelright",
+	MouseBackward:   "backward",
+	MouseForward:    "forward",
+	MouseExtra1:     "button10",
+	MouseExtra2:     "button11",
+}
+
+// Mouse represents a mouse message.
+type Mouse struct {
+	X, Y   int
+	Button MouseButton
+	Mod    KeyMod
+}
+
+// String returns a string representation of the mouse message.
+func (m Mouse) String() (s string) {
+	if m.Mod.Contains(ModCtrl) {
+		s += "ctrl+"
+	}
+	if m.Mod.Contains(ModAlt) {
+		s += "alt+"
+	}
+	if m.Mod.Contains(ModShift) {
+		s += "shift+"
+	}
+
+	str, ok := mouseButtons[m.Button]
+	if !ok {
+		s += "unknown"
+	} else if str != "none" { // motion events don't have a button
+		s += str
+	}
+
+	return s
+}
+
+// MouseClickMsg represents a mouse button click message.
+type MouseClickMsg Mouse
+
+// String returns a string representation of the mouse click message.
+func (e MouseClickMsg) String() string {
+	return Mouse(e).String()
+}
+
+// MouseReleaseMsg represents a mouse button release message.
+type MouseReleaseMsg Mouse
+
+// String returns a string representation of the mouse release message.
+func (e MouseReleaseMsg) String() string {
+	return Mouse(e).String()
+}
+
+// MouseWheelMsg represents a mouse wheel message event.
+type MouseWheelMsg Mouse
+
+// String returns a string representation of the mouse wheel message.
+func (e MouseWheelMsg) String() string {
+	return Mouse(e).String()
+}
+
+// MouseMotionMsg represents a mouse motion message.
+type MouseMotionMsg Mouse
+
+// String returns a string representation of the mouse motion message.
+func (e MouseMotionMsg) String() string {
+	m := Mouse(e)
+	if m.Button != 0 {
+		return m.String() + "+motion"
+	}
+	return m.String() + "motion"
+}
+
 // Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
 // look like:
 //
@@ -169,35 +132,28 @@ const (
 //	M is for button press, m is for button release
 //
 // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
-func parseSGRMouseEvent(buf []byte) MouseEvent {
-	str := string(buf[3:])
-	matches := mouseSGRRegex.FindStringSubmatch(str)
-	if len(matches) != 5 { //nolint:gomnd
-		// Unreachable, we already checked the regex in `detectOneMsg`.
-		panic("invalid mouse event")
-	}
+func parseSGRMouseEvent(csi *ansi.CsiSequence) Msg {
+	x := csi.Param(1)
+	y := csi.Param(2)
+	release := csi.Command() == 'm'
+	mod, btn, _, isMotion := parseMouseButton(csi.Param(0))
+
+	// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
+	x--
+	y--
 
-	b, _ := strconv.Atoi(matches[1])
-	px := matches[2]
-	py := matches[3]
-	release := matches[4] == "m"
-	m := parseMouseButton(b, true)
+	m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
 
 	// Wheel buttons don't have release events
 	// Motion can be reported as a release event in some terminals (Windows Terminal)
-	if m.Action != MouseActionMotion && !m.IsWheel() && release {
-		m.Action = MouseActionRelease
-		m.Type = MouseRelease
+	if isWheel(m.Button) {
+		return MouseWheelMsg(m)
+	} else if !isMotion && release {
+		return MouseReleaseMsg(m)
+	} else if isMotion {
+		return MouseMotionMsg(m)
 	}
-
-	x, _ := strconv.Atoi(px)
-	y, _ := strconv.Atoi(py)
-
-	// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
-	m.X = x - 1
-	m.Y = y - 1
-
-	return m
+	return MouseClickMsg(m)
 }
 
 const x10MouseByteOffset = 32
@@ -211,25 +167,34 @@ const x10MouseByteOffset = 32
 //	ESC [M Cb Cx Cy
 //
 // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
-func parseX10MouseEvent(buf []byte) MouseEvent {
+func parseX10MouseEvent(buf []byte) Msg {
 	v := buf[3:6]
-	m := parseMouseButton(int(v[0]), false)
+	b := int(v[0])
+	if b >= x10MouseByteOffset {
+		// XXX: b < 32 should be impossible, but we're being defensive.
+		b -= x10MouseByteOffset
+	}
 
-	// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
-	m.X = int(v[1]) - x10MouseByteOffset - 1
-	m.Y = int(v[2]) - x10MouseByteOffset - 1
+	mod, btn, isRelease, isMotion := parseMouseButton(b)
 
-	return m
+	// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
+	x := int(v[1]) - x10MouseByteOffset - 1
+	y := int(v[2]) - x10MouseByteOffset - 1
+
+	m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
+	if isWheel(m.Button) {
+		return MouseWheelMsg(m)
+	} else if isMotion {
+		return MouseMotionMsg(m)
+	} else if isRelease {
+		return MouseReleaseMsg(m)
+	}
+	return MouseClickMsg(m)
 }
 
 // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
-func parseMouseButton(b int, isSGR bool) MouseEvent {
-	var m MouseEvent
-	e := b
-	if !isSGR {
-		e -= x10MouseByteOffset
-	}
-
+func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) {
+	// mouse bit shifts
 	const (
 		bitShift  = 0b0000_0100
 		bitAlt    = 0b0000_1000
@@ -241,68 +206,39 @@ func parseMouseButton(b int, isSGR bool) MouseEvent {
 		bitsMask = 0b0000_0011
 	)
 
-	if e&bitAdd != 0 {
-		m.Button = MouseButtonBackward + MouseButton(e&bitsMask)
-	} else if e&bitWheel != 0 {
-		m.Button = MouseButtonWheelUp + MouseButton(e&bitsMask)
+	// Modifiers
+	if b&bitAlt != 0 {
+		mod |= ModAlt
+	}
+	if b&bitCtrl != 0 {
+		mod |= ModCtrl
+	}
+	if b&bitShift != 0 {
+		mod |= ModShift
+	}
+
+	if b&bitAdd != 0 {
+		btn = MouseBackward + MouseButton(b&bitsMask)
+	} else if b&bitWheel != 0 {
+		btn = MouseWheelUp + MouseButton(b&bitsMask)
 	} else {
-		m.Button = MouseButtonLeft + MouseButton(e&bitsMask)
+		btn = MouseLeft + MouseButton(b&bitsMask)
 		// X10 reports a button release as 0b0000_0011 (3)
-		if e&bitsMask == bitsMask {
-			m.Action = MouseActionRelease
-			m.Button = MouseButtonNone
+		if b&bitsMask == bitsMask {
+			btn = MouseNone
+			isRelease = true
 		}
 	}
 
 	// Motion bit doesn't get reported for wheel events.
-	if e&bitMotion != 0 && !m.IsWheel() {
-		m.Action = MouseActionMotion
+	if b&bitMotion != 0 && !isWheel(btn) {
+		isMotion = true
 	}
 
-	// Modifiers
-	m.Alt = e&bitAlt != 0
-	m.Ctrl = e&bitCtrl != 0
-	m.Shift = e&bitShift != 0
-
-	// backward compatibility
-	switch {
-	case m.Button == MouseButtonLeft && m.Action == MouseActionPress:
-		m.Type = MouseLeft
-	case m.Button == MouseButtonMiddle && m.Action == MouseActionPress:
-		m.Type = MouseMiddle
-	case m.Button == MouseButtonRight && m.Action == MouseActionPress:
-		m.Type = MouseRight
-	case m.Button == MouseButtonNone && m.Action == MouseActionRelease:
-		m.Type = MouseRelease
-	case m.Button == MouseButtonWheelUp && m.Action == MouseActionPress:
-		m.Type = MouseWheelUp
-	case m.Button == MouseButtonWheelDown && m.Action == MouseActionPress:
-		m.Type = MouseWheelDown
-	case m.Button == MouseButtonWheelLeft && m.Action == MouseActionPress:
-		m.Type = MouseWheelLeft
-	case m.Button == MouseButtonWheelRight && m.Action == MouseActionPress:
-		m.Type = MouseWheelRight
-	case m.Button == MouseButtonBackward && m.Action == MouseActionPress:
-		m.Type = MouseBackward
-	case m.Button == MouseButtonForward && m.Action == MouseActionPress:
-		m.Type = MouseForward
-	case m.Action == MouseActionMotion:
-		m.Type = MouseMotion
-		switch m.Button { //nolint:exhaustive
-		case MouseButtonLeft:
-			m.Type = MouseLeft
-		case MouseButtonMiddle:
-			m.Type = MouseMiddle
-		case MouseButtonRight:
-			m.Type = MouseRight
-		case MouseButtonBackward:
-			m.Type = MouseBackward
-		case MouseButtonForward:
-			m.Type = MouseForward
-		}
-	default:
-		m.Type = MouseUnknown
-	}
+	return
+}
 
-	return m
+// isWheel returns true if the mouse event is a wheel event.
+func isWheel(btn MouseButton) bool {
+	return btn >= MouseWheelUp && btn <= MouseWheelRight
 }
diff --git a/mouse_deprecated.go b/mouse_deprecated.go
new file mode 100644
index 0000000..714b63f
--- /dev/null
+++ b/mouse_deprecated.go
@@ -0,0 +1,162 @@
+package tea
+
+// MouseMsg contains information about a mouse event and are sent to a programs
+// update function when mouse activity occurs. Note that the mouse must first
+// be enabled in order for the mouse events to be received.
+//
+// Deprecated in favor of MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, and
+// MouseMotionMsg.
+type MouseMsg struct {
+	X      int
+	Y      int
+	Shift  bool
+	Alt    bool
+	Ctrl   bool
+	Action MouseAction
+	Button MouseButton
+	Type   MouseEventType
+}
+
+// MouseEvent represents a mouse event.
+//
+// Deprecated: Use Mouse.
+type MouseEvent = MouseMsg
+
+// IsWheel returns true if the mouse event is a wheel event.
+func (m MouseMsg) IsWheel() bool {
+	return m.Button == MouseButtonWheelUp || m.Button == MouseButtonWheelDown ||
+		m.Button == MouseButtonWheelLeft || m.Button == MouseButtonWheelRight
+}
+
+// String returns a string representation of a mouse event.
+func (m MouseMsg) String() (s string) {
+	if m.Ctrl {
+		s += "ctrl+"
+	}
+	if m.Alt {
+		s += "alt+"
+	}
+	if m.Shift {
+		s += "shift+"
+	}
+
+	if m.Button == MouseButtonNone { //nolint:nestif
+		if m.Action == MouseActionMotion || m.Action == MouseActionRelease {
+			s += mouseMsgActions[m.Action]
+		} else {
+			s += "unknown"
+		}
+	} else if m.IsWheel() {
+		s += mouseMsgButtons[m.Button]
+	} else {
+		btn := mouseMsgButtons[m.Button]
+		if btn != "" {
+			s += btn
+		}
+		act := mouseMsgActions[m.Action]
+		if act != "" {
+			s += " " + act
+		}
+	}
+
+	return s
+}
+
+// MouseAction represents the action that occurred during a mouse event.
+//
+// Deprecated: Use MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, and
+// MouseMotionMsg.
+type MouseAction int
+
+// Mouse event actions.
+//
+// Deprecated in favor of MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, and
+// MouseMotionMsg.
+const (
+	MouseActionPress MouseAction = iota
+	MouseActionRelease
+	MouseActionMotion
+)
+
+var mouseMsgActions = map[MouseAction]string{
+	MouseActionPress:   "press",
+	MouseActionRelease: "release",
+	MouseActionMotion:  "motion",
+}
+
+// Mouse event buttons
+//
+// This is based on X11 mouse button codes.
+//
+//	1 = left button
+//	2 = middle button (pressing the scroll wheel)
+//	3 = right button
+//	4 = turn scroll wheel up
+//	5 = turn scroll wheel down
+//	6 = push scroll wheel left
+//	7 = push scroll wheel right
+//	8 = 4th button (aka browser backward button)
+//	9 = 5th button (aka browser forward button)
+//	10
+//	11
+//
+// Other buttons are not supported.
+//
+// Deprecated: Use MouseNone, MouseLeft, etc.
+const (
+	MouseButtonNone       = MouseNone
+	MouseButtonLeft       = MouseLeft
+	MouseButtonMiddle     = MouseMiddle
+	MouseButtonRight      = MouseRight
+	MouseButtonWheelUp    = MouseWheelUp
+	MouseButtonWheelDown  = MouseWheelDown
+	MouseButtonWheelLeft  = MouseWheelLeft
+	MouseButtonWheelRight = MouseWheelRight
+	MouseButtonBackward   = MouseBackward
+	MouseButtonForward    = MouseForward
+	MouseButton10         = MouseExtra1
+	MouseButton11         = MouseExtra2
+)
+
+// Deprecated: Use mouseButtons.
+var mouseMsgButtons = map[MouseButton]string{
+	MouseButtonNone:       "none",
+	MouseButtonLeft:       "left",
+	MouseButtonMiddle:     "middle",
+	MouseButtonRight:      "right",
+	MouseButtonWheelUp:    "wheel up",
+	MouseButtonWheelDown:  "wheel down",
+	MouseButtonWheelLeft:  "wheel left",
+	MouseButtonWheelRight: "wheel right",
+	MouseButtonBackward:   "backward",
+	MouseButtonForward:    "forward",
+	MouseButton10:         "button 10",
+	MouseButton11:         "button 11",
+}
+
+// MouseEventType indicates the type of mouse event occurring.
+//
+// Deprecated: Use MouseButton.
+type MouseEventType = MouseButton
+
+// Mouse event types.
+//
+// Deprecated in favor of MouseReleaseMsg and MouseMotionMsg.
+const (
+	MouseUnknown = MouseNone
+
+	MouseRelease MouseEventType = -iota // mouse button release (X10 only)
+	MouseMotion
+)
+
+// toMouseMsg converts a mouse event to a mouse message.
+func toMouseMsg(m Mouse) MouseMsg {
+	return MouseMsg{
+		X:      m.X,
+		Y:      m.Y,
+		Shift:  m.Mod.Contains(ModShift),
+		Alt:    m.Mod.Contains(ModAlt),
+		Ctrl:   m.Mod.Contains(ModCtrl),
+		Button: m.Button,
+	}
+}
diff --git a/nil_renderer.go b/nil_renderer.go
index f4a83b6..19ad1c5 100644
--- a/nil_renderer.go
+++ b/nil_renderer.go
@@ -2,24 +2,15 @@ package tea
 
 type nilRenderer struct{}
 
-func (n nilRenderer) start()                     {}
-func (n nilRenderer) stop()                      {}
-func (n nilRenderer) kill()                      {}
-func (n nilRenderer) write(_ string)             {}
-func (n nilRenderer) repaint()                   {}
-func (n nilRenderer) clearScreen()               {}
-func (n nilRenderer) altScreen() bool            { return false }
-func (n nilRenderer) enterAltScreen()            {}
-func (n nilRenderer) exitAltScreen()             {}
-func (n nilRenderer) showCursor()                {}
-func (n nilRenderer) hideCursor()                {}
-func (n nilRenderer) enableMouseCellMotion()     {}
-func (n nilRenderer) disableMouseCellMotion()    {}
-func (n nilRenderer) enableMouseAllMotion()      {}
-func (n nilRenderer) disableMouseAllMotion()     {}
-func (n nilRenderer) enableBracketedPaste()      {}
-func (n nilRenderer) disableBracketedPaste()     {}
-func (n nilRenderer) enableMouseSGRMode()        {}
-func (n nilRenderer) disableMouseSGRMode()       {}
-func (n nilRenderer) bracketedPasteActive() bool { return false }
-func (n nilRenderer) setWindowTitle(_ string)    {}
+func (nilRenderer) start()          {}
+func (nilRenderer) stop()           {}
+func (nilRenderer) kill()           {}
+func (nilRenderer) write(string)    {}
+func (nilRenderer) repaint()        {}
+func (nilRenderer) clearScreen()    {}
+func (nilRenderer) altScreen() bool { return false }
+func (nilRenderer) enterAltScreen() {}
+func (nilRenderer) exitAltScreen()  {}
+func (nilRenderer) showCursor()     {}
+func (nilRenderer) hideCursor()     {}
+func (nilRenderer) execute(string)  {}
diff --git a/parse.go b/parse.go
new file mode 100644
index 0000000..49eae92
--- /dev/null
+++ b/parse.go
@@ -0,0 +1,811 @@
+package tea
+
+import (
+	"encoding/base64"
+	"strings"
+	"unicode/utf8"
+
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/ansi/parser"
+	"github.com/erikgeiser/coninput"
+	"github.com/rivo/uniseg"
+)
+
+// Flags to control the behavior of the parser.
+// TODO: Should these be exported?
+const (
+	// When this flag is set, the driver will treat both Ctrl+Space and Ctrl+@
+	// as the same key sequence.
+	//
+	// Historically, the ANSI specs generate NUL (0x00) on both the Ctrl+Space
+	// and Ctrl+@ key sequences. This flag allows the driver to treat both as
+	// the same key sequence.
+	_FlagCtrlAt = 1 << iota
+
+	// When this flag is set, the driver will treat the Tab key and Ctrl+I as
+	// the same key sequence.
+	//
+	// Historically, the ANSI specs generate HT (0x09) on both the Tab key and
+	// Ctrl+I. This flag allows the driver to treat both as the same key
+	// sequence.
+	_FlagCtrlI
+
+	// When this flag is set, the driver will treat the Enter key and Ctrl+M as
+	// the same key sequence.
+	//
+	// Historically, the ANSI specs generate CR (0x0D) on both the Enter key
+	// and Ctrl+M. This flag allows the driver to treat both as the same key
+	_FlagCtrlM
+
+	// When this flag is set, the driver will treat Escape and Ctrl+[ as
+	// the same key sequence.
+	//
+	// Historically, the ANSI specs generate ESC (0x1B) on both the Escape key
+	// and Ctrl+[. This flag allows the driver to treat both as the same key
+	// sequence.
+	_FlagCtrlOpenBracket
+
+	// When this flag is set, the driver will send a BS (0x08 byte) character
+	// instead of a DEL (0x7F byte) character when the Backspace key is
+	// pressed.
+	//
+	// The VT100 terminal has both a Backspace and a Delete key. The VT220
+	// terminal dropped the Backspace key and replaced it with the Delete key.
+	// Both terminals send a DEL character when the Delete key is pressed.
+	// Modern terminals and PCs later readded the Delete key but used a
+	// different key sequence, and the Backspace key was standardized to send a
+	// DEL character.
+	_FlagBackspace
+
+	// When this flag is set, the driver will recognize the Find key instead of
+	// treating it as a Home key.
+	//
+	// The Find key was part of the VT220 keyboard, and is no longer used in
+	// modern day PCs.
+	_FlagFind
+
+	// When this flag is set, the driver will recognize the Select key instead
+	// of treating it as a End key.
+	//
+	// The Symbol key was part of the VT220 keyboard, and is no longer used in
+	// modern day PCs.
+	_FlagSelect
+
+	// When this flag is set, the driver will use Terminfo databases to
+	// overwrite the default key sequences.
+	_FlagTerminfo
+
+	// When this flag is set, the driver will preserve function keys (F13-F63)
+	// as symbols.
+	//
+	// Since these keys are not part of today's standard 20th century keyboard,
+	// we treat them as F1-F12 modifier keys i.e. ctrl/shift/alt + Fn combos.
+	// Key definitions come from Terminfo, this flag is only useful when
+	// FlagTerminfo is not set.
+	_FlagFKeys
+)
+
+var flags int
+
+// setFlags sets the flags for the parser.
+// This will control the behavior of ParseSequence.
+//
+//nolint:unused
+func setFlags(f int) {
+	flags = f
+}
+
+// parseSequence finds the first recognized event sequence and returns it along
+// with its length.
+//
+// It will return zero and nil no sequence is recognized or when the buffer is
+// empty. If a sequence is not supported, an UnknownEvent is returned.
+func parseSequence(buf []byte) (n int, msg Msg) {
+	if len(buf) == 0 {
+		return 0, nil
+	}
+
+	switch b := buf[0]; b {
+	case ansi.ESC:
+		if len(buf) == 1 {
+			// Escape key
+			return 1, KeyPressMsg{Type: KeyEscape}
+		}
+
+		switch b := buf[1]; b {
+		case 'O': // Esc-prefixed SS3
+			return parseSs3(buf)
+		case 'P': // Esc-prefixed DCS
+			return parseDcs(buf)
+		case '[': // Esc-prefixed CSI
+			return parseCsi(buf)
+		case ']': // Esc-prefixed OSC
+			return parseOsc(buf)
+		case '_': // Esc-prefixed APC
+			return parseApc(buf)
+		default:
+			n, e := parseSequence(buf[1:])
+			if k, ok := e.(KeyPressMsg); ok {
+				k.Mod |= ModAlt
+				return n + 1, k
+			}
+
+			// Not a key sequence, nor an alt modified key sequence. In that
+			// case, just report a single escape key.
+			return 1, KeyPressMsg{Type: KeyEscape}
+		}
+	case ansi.SS3:
+		return parseSs3(buf)
+	case ansi.DCS:
+		return parseDcs(buf)
+	case ansi.CSI:
+		return parseCsi(buf)
+	case ansi.OSC:
+		return parseOsc(buf)
+	case ansi.APC:
+		return parseApc(buf)
+	default:
+		if b <= ansi.US || b == ansi.DEL || b == ansi.SP {
+			return 1, parseControl(b)
+		} else if b >= ansi.PAD && b <= ansi.APC {
+			// C1 control code
+			// UTF-8 never starts with a C1 control code
+			// Encode these as Ctrl+Alt+<code - 0x40>
+			return 1, KeyPressMsg{Runes: []rune{rune(b) - 0x40}, Mod: ModCtrl | ModAlt}
+		}
+		return parseUtf8(buf)
+	}
+}
+
+func parseCsi(b []byte) (int, Msg) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+[ key
+		return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt}
+	}
+
+	var csi ansi.CsiSequence
+	var params [parser.MaxParamsSize]int
+	var paramsLen int
+
+	var i int
+	if b[i] == ansi.CSI || b[i] == ansi.ESC {
+		i++
+	}
+	if i < len(b) && b[i-1] == ansi.ESC && b[i] == '[' {
+		i++
+	}
+
+	// Initial CSI byte
+	if i < len(b) && b[i] >= '<' && b[i] <= '?' {
+		csi.Cmd |= int(b[i]) << parser.MarkerShift
+	}
+
+	// Scan parameter bytes in the range 0x30-0x3F
+	var j int
+	for j = 0; i < len(b) && paramsLen < len(params) && b[i] >= 0x30 && b[i] <= 0x3F; i, j = i+1, j+1 {
+		if b[i] >= '0' && b[i] <= '9' {
+			if params[paramsLen] == parser.MissingParam {
+				params[paramsLen] = 0
+			}
+			params[paramsLen] *= 10
+			params[paramsLen] += int(b[i]) - '0'
+		}
+		if b[i] == ':' {
+			params[paramsLen] |= parser.HasMoreFlag
+		}
+		if b[i] == ';' || b[i] == ':' {
+			paramsLen++
+			if paramsLen < len(params) {
+				// Don't overflow the params slice
+				params[paramsLen] = parser.MissingParam
+			}
+		}
+	}
+
+	if j > 0 && paramsLen < len(params) {
+		// has parameters
+		paramsLen++
+	}
+
+	// Scan intermediate bytes in the range 0x20-0x2F
+	var intermed byte
+	for ; i < len(b) && b[i] >= 0x20 && b[i] <= 0x2F; i++ {
+		intermed = b[i]
+	}
+
+	// Set the intermediate byte
+	csi.Cmd |= int(intermed) << parser.IntermedShift
+
+	// Scan final byte in the range 0x40-0x7E
+	if i >= len(b) || b[i] < 0x40 || b[i] > 0x7E {
+		// Special case for URxvt keys
+		// CSI <number> $ is an invalid sequence, but URxvt uses it for
+		// shift modified keys.
+		if b[i-1] == '$' {
+			n, ev := parseCsi(append(b[:i-1], '~'))
+			if k, ok := ev.(KeyPressMsg); ok {
+				k.Mod |= ModShift
+				return n, k
+			}
+		}
+		return i, UnknownMsg(b[:i-1])
+	}
+
+	// Add the final byte
+	csi.Cmd |= int(b[i])
+	i++
+
+	csi.Params = params[:paramsLen]
+	switch cmd := csi.Cmd; cmd {
+	case 'y' | '?'<<parser.MarkerShift | '$'<<parser.IntermedShift:
+		// Report Mode (DECRPM)
+		if paramsLen == 2 && csi.Param(0) != -1 && csi.Param(1) != -1 {
+			return i, ReportModeMsg{Mode: csi.Param(0), Value: csi.Param(1)}
+		}
+	case 'c' | '?'<<parser.MarkerShift:
+		// Primary Device Attributes
+		return i, parsePrimaryDevAttrs(&csi)
+	case 'u' | '?'<<parser.MarkerShift:
+		// Kitty keyboard flags
+		if param := csi.Param(0); param != -1 {
+			return i, KittyKeyboardMsg(param)
+		}
+	case 'R' | '?'<<parser.MarkerShift:
+		// This report may return a third parameter representing the page
+		// number, but we don't really need it.
+		if paramsLen >= 2 && csi.Param(0) != -1 && csi.Param(1) != -1 {
+			return i, CursorPositionMsg{Row: csi.Param(0), Column: csi.Param(1)}
+		}
+	case 'm' | '<'<<parser.MarkerShift, 'M' | '<'<<parser.MarkerShift:
+		// Handle SGR mouse
+		if paramsLen == 3 {
+			return i, parseSGRMouseEvent(&csi)
+		}
+	case 'm' | '>'<<parser.MarkerShift:
+		// XTerm modifyOtherKeys
+		if paramsLen == 2 && csi.Param(0) == 4 && csi.Param(1) != -1 {
+			return i, ModifyOtherKeysMsg(csi.Param(1))
+		}
+	case 'I':
+		return i, FocusMsg{}
+	case 'O':
+		return i, BlurMsg{}
+	case 'R':
+		// Cursor position report OR modified F3
+		if paramsLen == 2 && csi.Param(0) != -1 && csi.Param(1) != -1 {
+			m := CursorPositionMsg{Row: csi.Param(0), Column: csi.Param(1)}
+			if csi.Param(0) == 1 && csi.Param(1)-1 <= int(ModMeta|ModShift|ModAlt|ModCtrl) {
+				// XXX: We cannot differentiate between cursor position report and
+				// CSI 1 ; <mod> R (which is modified F3) when the cursor is at the
+				// row 1. In this case, we report both messages.
+				//
+				// For a non ambiguous cursor position report, use
+				// [ansi.RequestExtendedCursorPosition] (DECXCPR) instead.
+				return i, multiMsg{KeyPressMsg{Type: KeyF3, Mod: KeyMod(csi.Param(1) - 1)}, m}
+			}
+
+			return i, m
+		}
+
+		if paramsLen != 0 {
+			break
+		}
+
+		// Unmodified key F3 (CSI R)
+		fallthrough
+	case 'a', 'b', 'c', 'd', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S', 'Z':
+		var k KeyPressMsg
+		switch cmd {
+		case 'a', 'b', 'c', 'd':
+			k = KeyPressMsg{Type: KeyUp + KeyType(cmd-'a'), Mod: ModShift}
+		case 'A', 'B', 'C', 'D':
+			k = KeyPressMsg{Type: KeyUp + KeyType(cmd-'A')}
+		case 'E':
+			k = KeyPressMsg{Type: KeyBegin}
+		case 'F':
+			k = KeyPressMsg{Type: KeyEnd}
+		case 'H':
+			k = KeyPressMsg{Type: KeyHome}
+		case 'P', 'Q', 'R', 'S':
+			k = KeyPressMsg{Type: KeyF1 + KeyType(cmd-'P')}
+		case 'Z':
+			k = KeyPressMsg{Type: KeyTab, Mod: ModShift}
+		}
+		if paramsLen > 1 && csi.Param(0) == 1 && csi.Param(1) != -1 {
+			// CSI 1 ; <modifiers> A
+			k.Mod |= KeyMod(csi.Param(1) - 1)
+		}
+		return i, k
+	case 'M':
+		// Handle X10 mouse
+		if i+3 > len(b) {
+			return i, UnknownMsg(b[:i])
+		}
+		return i + 3, parseX10MouseEvent(append(b[:i], b[i:i+3]...))
+	case 'y':
+		// Report Mode (DECRPM)
+		if paramsLen != 2 && csi.Param(0) != -1 && csi.Param(0) != -1 {
+			return i, UnknownMsg(b[:i])
+		}
+		return i, ReportModeMsg{Mode: csi.Param(0), Value: csi.Param(1)}
+	case 'u':
+		// Kitty keyboard protocol & CSI u (fixterms)
+		if paramsLen == 0 {
+			return i, UnknownMsg(b[:i])
+		}
+		return i, parseKittyKeyboard(&csi)
+	case '_':
+		// Win32 Input Mode
+		if paramsLen != 6 {
+			return i, UnknownMsg(b[:i])
+		}
+
+		rc := uint16(csi.Param(5))
+		if rc == 0 {
+			rc = 1
+		}
+
+		event := parseWin32InputKeyEvent(
+			coninput.VirtualKeyCode(csi.Param(0)),  // Vk wVirtualKeyCode
+			coninput.VirtualKeyCode(csi.Param(1)),  // Sc wVirtualScanCode
+			rune(csi.Param(2)),                     // Uc UnicodeChar
+			csi.Param(3) == 1,                      // Kd bKeyDown
+			coninput.ControlKeyState(csi.Param(4)), // Cs dwControlKeyState
+			rc,                                     // Rc wRepeatCount
+		)
+
+		if event == nil {
+			return i, UnknownMsg(b[:])
+		}
+
+		return i, event
+	case '@', '^', '~':
+		if paramsLen == 0 {
+			return i, UnknownMsg(b[:i])
+		}
+
+		param := csi.Param(0)
+		switch cmd {
+		case '~':
+			switch param {
+			case 27:
+				// XTerm modifyOtherKeys 2
+				if paramsLen != 3 {
+					return i, UnknownMsg(b[:i])
+				}
+				return i, parseXTermModifyOtherKeys(&csi)
+			case 200:
+				// bracketed-paste start
+				return i, PasteStartMsg{}
+			case 201:
+				// bracketed-paste end
+				return i, PasteEndMsg{}
+			}
+		}
+
+		switch param {
+		case 1, 2, 3, 4, 5, 6, 7, 8,
+			11, 12, 13, 14, 15,
+			17, 18, 19, 20, 21,
+			23, 24, 25, 26,
+			28, 29, 31, 32, 33, 34:
+			var k KeyPressMsg
+			switch param {
+			case 1:
+				if flags&_FlagFind != 0 {
+					k = KeyPressMsg{Type: KeyFind}
+				} else {
+					k = KeyPressMsg{Type: KeyHome}
+				}
+			case 2:
+				k = KeyPressMsg{Type: KeyInsert}
+			case 3:
+				k = KeyPressMsg{Type: KeyDelete}
+			case 4:
+				if flags&_FlagSelect != 0 {
+					k = KeyPressMsg{Type: KeySelect}
+				} else {
+					k = KeyPressMsg{Type: KeyEnd}
+				}
+			case 5:
+				k = KeyPressMsg{Type: KeyPgUp}
+			case 6:
+				k = KeyPressMsg{Type: KeyPgDown}
+			case 7:
+				k = KeyPressMsg{Type: KeyHome}
+			case 8:
+				k = KeyPressMsg{Type: KeyEnd}
+			case 11, 12, 13, 14, 15:
+				k = KeyPressMsg{Type: KeyF1 + KeyType(param-11)}
+			case 17, 18, 19, 20, 21:
+				k = KeyPressMsg{Type: KeyF6 + KeyType(param-17)}
+			case 23, 24, 25, 26:
+				k = KeyPressMsg{Type: KeyF11 + KeyType(param-23)}
+			case 28, 29:
+				k = KeyPressMsg{Type: KeyF15 + KeyType(param-28)}
+			case 31, 32, 33, 34:
+				k = KeyPressMsg{Type: KeyF17 + KeyType(param-31)}
+			}
+
+			// modifiers
+			if paramsLen > 1 && csi.Param(1) != -1 {
+				k.Mod |= KeyMod(csi.Param(1) - 1)
+			}
+
+			// Handle URxvt weird keys
+			switch cmd {
+			case '^':
+				k.Mod |= ModCtrl
+			case '@':
+				k.Mod |= ModCtrl | ModShift
+			}
+
+			return i, k
+		}
+	}
+	return i, UnknownMsg(b[:i])
+}
+
+// parseSs3 parses a SS3 sequence.
+// See https://vt100.net/docs/vt220-rm/chapter4.html#S4.4.4.2
+func parseSs3(b []byte) (int, Msg) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+O key
+		return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt}
+	}
+
+	var i int
+	if b[i] == ansi.SS3 || b[i] == ansi.ESC {
+		i++
+	}
+	if i < len(b) && b[i-1] == ansi.ESC && b[i] == 'O' {
+		i++
+	}
+
+	// Scan numbers from 0-9
+	var mod int
+	for ; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ {
+		mod *= 10
+		mod += int(b[i]) - '0'
+	}
+
+	// Scan a GL character
+	// A GL character is a single byte in the range 0x21-0x7E
+	// See https://vt100.net/docs/vt220-rm/chapter2.html#S2.3.2
+	if i >= len(b) || b[i] < 0x21 || b[i] > 0x7E {
+		return i, UnknownMsg(b[:i])
+	}
+
+	// GL character(s)
+	gl := b[i]
+	i++
+
+	var k KeyPressMsg
+	switch gl {
+	case 'a', 'b', 'c', 'd':
+		k = KeyPressMsg{Type: KeyUp + KeyType(gl-'a'), Mod: ModCtrl}
+	case 'A', 'B', 'C', 'D':
+		k = KeyPressMsg{Type: KeyUp + KeyType(gl-'A')}
+	case 'E':
+		k = KeyPressMsg{Type: KeyBegin}
+	case 'F':
+		k = KeyPressMsg{Type: KeyEnd}
+	case 'H':
+		k = KeyPressMsg{Type: KeyHome}
+	case 'P', 'Q', 'R', 'S':
+		k = KeyPressMsg{Type: KeyF1 + KeyType(gl-'P')}
+	case 'M':
+		k = KeyPressMsg{Type: KeyKpEnter}
+	case 'X':
+		k = KeyPressMsg{Type: KeyKpEqual}
+	case 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y':
+		k = KeyPressMsg{Type: KeyKpMultiply + KeyType(gl-'j')}
+	default:
+		return i, UnknownMsg(b[:i])
+	}
+
+	// Handle weird SS3 <modifier> Func
+	if mod > 0 {
+		k.Mod |= KeyMod(mod - 1)
+	}
+
+	return i, k
+}
+
+func parseOsc(b []byte) (int, Msg) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+] key
+		return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt}
+	}
+
+	var i int
+	if b[i] == ansi.OSC || b[i] == ansi.ESC {
+		i++
+	}
+	if i < len(b) && b[i-1] == ansi.ESC && b[i] == ']' {
+		i++
+	}
+
+	// Parse OSC command
+	// An OSC sequence is terminated by a BEL, ESC, or ST character
+	var start, end int
+	cmd := -1
+	for ; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ {
+		if cmd == -1 {
+			cmd = 0
+		} else {
+			cmd *= 10
+		}
+		cmd += int(b[i]) - '0'
+	}
+
+	if i < len(b) && b[i] == ';' {
+		// mark the start of the sequence data
+		i++
+		start = i
+	}
+
+	for ; i < len(b); i++ {
+		// advance to the end of the sequence
+		if b[i] == ansi.BEL || b[i] == ansi.ESC || b[i] == ansi.ST {
+			break
+		}
+	}
+
+	if i >= len(b) {
+		return i, UnknownMsg(b[:i])
+	}
+
+	end = i // end of the sequence data
+	i++
+
+	// Check 7-bit ST (string terminator) character
+	if i < len(b) && b[i-1] == ansi.ESC && b[i] == '\\' {
+		i++
+	}
+
+	if end <= start {
+		return i, UnknownMsg(b[:i])
+	}
+
+	data := string(b[start:end])
+	switch cmd {
+	case 10:
+		return i, ForegroundColorMsg{xParseColor(data)}
+	case 11:
+		return i, BackgroundColorMsg{xParseColor(data)}
+	case 12:
+		return i, CursorColorMsg{xParseColor(data)}
+	case 52:
+		parts := strings.Split(data, ";")
+		if len(parts) == 0 {
+			return i, ClipboardMsg("")
+		}
+		b64 := parts[len(parts)-1]
+		bts, err := base64.StdEncoding.DecodeString(b64)
+		if err != nil {
+			return i, ClipboardMsg("")
+		}
+		return i, ClipboardMsg(bts)
+	default:
+		return i, UnknownMsg(b[:i])
+	}
+}
+
+// parseStTerminated parses a control sequence that gets terminated by a ST character.
+func parseStTerminated(intro8, intro7 byte) func([]byte) (int, Msg) {
+	return func(b []byte) (int, Msg) {
+		var i int
+		if b[i] == intro8 || b[i] == ansi.ESC {
+			i++
+		}
+		if i < len(b) && b[i-1] == ansi.ESC && b[i] == intro7 {
+			i++
+		}
+
+		// Scan control sequence
+		// Most common control sequence is terminated by a ST character
+		// ST is a 7-bit string terminator character is (ESC \)
+		// nolint: revive
+		for ; i < len(b) && b[i] != ansi.ST && b[i] != ansi.ESC; i++ {
+		}
+
+		if i >= len(b) {
+			return i, UnknownMsg(b[:i])
+		}
+		i++
+
+		// Check 7-bit ST (string terminator) character
+		if i < len(b) && b[i-1] == ansi.ESC && b[i] == '\\' {
+			i++
+		}
+
+		return i, UnknownMsg(b[:i])
+	}
+}
+
+func parseDcs(b []byte) (int, Msg) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+P key
+		return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt}
+	}
+
+	var params [16]int
+	var paramsLen int
+	var dcs ansi.DcsSequence
+
+	// DCS sequences are introduced by DCS (0x90) or ESC P (0x1b 0x50)
+	var i int
+	if b[i] == ansi.DCS || b[i] == ansi.ESC {
+		i++
+	}
+	if i < len(b) && b[i-1] == ansi.ESC && b[i] == 'P' {
+		i++
+	}
+
+	// initial DCS byte
+	if i < len(b) && b[i] >= '<' && b[i] <= '?' {
+		dcs.Cmd |= int(b[i]) << parser.MarkerShift
+	}
+
+	// Scan parameter bytes in the range 0x30-0x3F
+	var j int
+	for j = 0; i < len(b) && paramsLen < len(params) && b[i] >= 0x30 && b[i] <= 0x3F; i, j = i+1, j+1 {
+		if b[i] >= '0' && b[i] <= '9' {
+			if params[paramsLen] == parser.MissingParam {
+				params[paramsLen] = 0
+			}
+			params[paramsLen] *= 10
+			params[paramsLen] += int(b[i]) - '0'
+		}
+		if b[i] == ':' {
+			params[paramsLen] |= parser.HasMoreFlag
+		}
+		if b[i] == ';' || b[i] == ':' {
+			paramsLen++
+			if paramsLen < len(params) {
+				// Don't overflow the params slice
+				params[paramsLen] = parser.MissingParam
+			}
+		}
+	}
+
+	if j > 0 && paramsLen < len(params) {
+		// has parameters
+		paramsLen++
+	}
+
+	// Scan intermediate bytes in the range 0x20-0x2F
+	var intermed byte
+	for j := 0; i < len(b) && b[i] >= 0x20 && b[i] <= 0x2F; i, j = i+1, j+1 {
+		intermed = b[i]
+	}
+
+	// set intermediate byte
+	dcs.Cmd |= int(intermed) << parser.IntermedShift
+
+	// Scan final byte in the range 0x40-0x7E
+	if i >= len(b) || b[i] < 0x40 || b[i] > 0x7E {
+		return i, UnknownMsg(b[:i])
+	}
+
+	// Add the final byte
+	dcs.Cmd |= int(b[i])
+	i++
+
+	start := i // start of the sequence data
+	for ; i < len(b); i++ {
+		if b[i] == ansi.ST || b[i] == ansi.ESC {
+			break
+		}
+	}
+
+	if i >= len(b) {
+		return i, UnknownMsg(b[:i])
+	}
+
+	end := i // end of the sequence data
+	i++
+
+	// Check 7-bit ST (string terminator) character
+	if i < len(b) && b[i-1] == ansi.ESC && b[i] == '\\' {
+		i++
+	}
+
+	dcs.Params = params[:paramsLen]
+	switch cmd := dcs.Command(); cmd {
+	case 'r':
+		switch dcs.Intermediate() {
+		case '+':
+			// XTGETTCAP responses
+			switch param := dcs.Param(0); param {
+			case 0, 1:
+				tc := parseTermcap(b[start:end])
+				// XXX: some terminals like KiTTY report invalid responses with
+				// their queries i.e. sending a query for "Tc" using "\x1bP+q5463\x1b\\"
+				// returns "\x1bP0+r5463\x1b\\".
+				// The specs says that invalid responses should be in the form of
+				// DCS 0 + r ST "\x1bP0+r\x1b\\"
+				//
+				// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+				return i, tc
+			}
+		}
+	}
+
+	return i, UnknownMsg(b[:i])
+}
+
+func parseApc(b []byte) (int, Msg) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+_ key
+		return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt}
+	}
+
+	// APC sequences are introduced by APC (0x9f) or ESC _ (0x1b 0x5f)
+	return parseStTerminated(ansi.APC, '_')(b)
+}
+
+func parseUtf8(b []byte) (int, Msg) {
+	if len(b) == 0 {
+		return 0, nil
+	}
+
+	c := b[0]
+	if c <= ansi.US || c == ansi.DEL || c == ansi.SP {
+		// Control codes get handled by parseControl
+		return 1, parseControl(c)
+	} else if c > ansi.US && c < ansi.DEL {
+		// ASCII printable characters
+		return 1, KeyPressMsg{Runes: []rune{rune(c)}}
+	}
+
+	if r, _ := utf8.DecodeRune(b); r == utf8.RuneError {
+		return 1, UnknownMsg(b[0])
+	}
+
+	cluster, _, _, _ := uniseg.FirstGraphemeCluster(b, -1)
+	return len(cluster), KeyPressMsg{Runes: []rune(string(cluster))}
+}
+
+func parseControl(b byte) Msg {
+	switch b {
+	case ansi.NUL:
+		if flags&_FlagCtrlAt != 0 {
+			return KeyPressMsg{Runes: []rune{'@'}, Mod: ModCtrl}
+		}
+		return KeyPressMsg{Runes: []rune{' '}, Type: KeySpace, Mod: ModCtrl}
+	case ansi.BS:
+		return KeyPressMsg{Runes: []rune{'h'}, Mod: ModCtrl}
+	case ansi.HT:
+		if flags&_FlagCtrlI != 0 {
+			return KeyPressMsg{Runes: []rune{'i'}, Mod: ModCtrl}
+		}
+		return KeyPressMsg{Type: KeyTab}
+	case ansi.CR:
+		if flags&_FlagCtrlM != 0 {
+			return KeyPressMsg{Runes: []rune{'m'}, Mod: ModCtrl}
+		}
+		return KeyPressMsg{Type: KeyEnter}
+	case ansi.ESC:
+		if flags&_FlagCtrlOpenBracket != 0 {
+			return KeyPressMsg{Runes: []rune{'['}, Mod: ModCtrl}
+		}
+		return KeyPressMsg{Type: KeyEscape}
+	case ansi.DEL:
+		if flags&_FlagBackspace != 0 {
+			return KeyPressMsg{Type: KeyDelete}
+		}
+		return KeyPressMsg{Type: KeyBackspace}
+	case ansi.SP:
+		return KeyPressMsg{Type: KeySpace, Runes: []rune{' '}}
+	default:
+		if b >= ansi.SOH && b <= ansi.SUB {
+			// Use lower case letters for control codes
+			return KeyPressMsg{Runes: []rune{rune(b + 0x60)}, Mod: ModCtrl}
+		} else if b >= ansi.FS && b <= ansi.US {
+			return KeyPressMsg{Runes: []rune{rune(b + 0x40)}, Mod: ModCtrl}
+		}
+		return UnknownMsg(b)
+	}
+}
diff --git a/paste.go b/paste.go
new file mode 100644
index 0000000..575f995
--- /dev/null
+++ b/paste.go
@@ -0,0 +1,13 @@
+package tea
+
+// PasteMsg is an message that is emitted when a terminal receives pasted text
+// using bracketed-paste.
+type PasteMsg string
+
+// PasteStartMsg is an message that is emitted when the terminal starts the
+// bracketed-paste text
+type PasteStartMsg struct{}
+
+// PasteEndMsg is an message that is emitted when the terminal ends the
+// bracketed-paste text.
+type PasteEndMsg struct{}
diff --git a/renderer.go b/renderer.go
index de3936e..a15f5ff 100644
--- a/renderer.go
+++ b/renderer.go
@@ -36,40 +36,8 @@ type renderer interface {
 	// Hide the cursor.
 	hideCursor()
 
-	// enableMouseCellMotion enables mouse click, release, wheel and motion
-	// events if a mouse button is pressed (i.e., drag events).
-	enableMouseCellMotion()
-
-	// disableMouseCellMotion disables Mouse Cell Motion tracking.
-	disableMouseCellMotion()
-
-	// enableMouseAllMotion enables mouse click, release, wheel and motion
-	// events, regardless of whether a mouse button is pressed. Many modern
-	// terminals support this, but not all.
-	enableMouseAllMotion()
-
-	// disableMouseAllMotion disables All Motion mouse tracking.
-	disableMouseAllMotion()
-
-	// enableMouseSGRMode enables mouse extended mode (SGR).
-	enableMouseSGRMode()
-
-	// disableMouseSGRMode disables mouse extended mode (SGR).
-	disableMouseSGRMode()
-
-	// enableBracketedPaste enables bracketed paste, where characters
-	// inside the input are not interpreted when pasted as a whole.
-	enableBracketedPaste()
-
-	// disableBracketedPaste disables bracketed paste.
-	disableBracketedPaste()
-
-	// bracketedPasteActive reports whether bracketed paste mode is
-	// currently enabled.
-	bracketedPasteActive() bool
-
-	// setWindowTitle sets the terminal window title.
-	setWindowTitle(string)
+	// execute writes a sequence to the terminal.
+	execute(string)
 }
 
 // repaintMsg forces a full repaint.
diff --git a/screen.go b/screen.go
index 6256c15..1f52dc2 100644
--- a/screen.go
+++ b/screen.go
@@ -1,5 +1,7 @@
 package tea
 
+import "github.com/charmbracelet/x/ansi"
+
 // WindowSizeMsg is used to report the terminal size. It's sent to Update once
 // initially and then on every terminal resize. Note that Windows does not
 // have support for reporting when resizes occur as it does not support the
@@ -173,7 +175,7 @@ func (p *Program) ExitAltScreen() {
 // Deprecated: Use the WithMouseCellMotion ProgramOption instead.
 func (p *Program) EnableMouseCellMotion() {
 	if p.renderer != nil {
-		p.renderer.enableMouseCellMotion()
+		p.renderer.execute(ansi.EnableMouseCellMotion)
 	} else {
 		p.startupOptions |= withMouseCellMotion
 	}
@@ -185,7 +187,7 @@ func (p *Program) EnableMouseCellMotion() {
 // Deprecated: The mouse will automatically be disabled when the program exits.
 func (p *Program) DisableMouseCellMotion() {
 	if p.renderer != nil {
-		p.renderer.disableMouseCellMotion()
+		p.renderer.execute(ansi.DisableMouseCellMotion)
 	} else {
 		p.startupOptions &^= withMouseCellMotion
 	}
@@ -198,7 +200,7 @@ func (p *Program) DisableMouseCellMotion() {
 // Deprecated: Use the WithMouseAllMotion ProgramOption instead.
 func (p *Program) EnableMouseAllMotion() {
 	if p.renderer != nil {
-		p.renderer.enableMouseAllMotion()
+		p.renderer.execute(ansi.EnableMouseAllMotion)
 	} else {
 		p.startupOptions |= withMouseAllMotion
 	}
@@ -210,7 +212,7 @@ func (p *Program) EnableMouseAllMotion() {
 // Deprecated: The mouse will automatically be disabled when the program exits.
 func (p *Program) DisableMouseAllMotion() {
 	if p.renderer != nil {
-		p.renderer.disableMouseAllMotion()
+		p.renderer.execute(ansi.DisableMouseAllMotion)
 	} else {
 		p.startupOptions &^= withMouseAllMotion
 	}
@@ -221,7 +223,7 @@ func (p *Program) DisableMouseAllMotion() {
 // Deprecated: Use the SetWindowTitle command instead.
 func (p *Program) SetWindowTitle(title string) {
 	if p.renderer != nil {
-		p.renderer.setWindowTitle(title)
+		p.renderer.execute(ansi.SetWindowTitle(title))
 	} else {
 		p.startupTitle = title
 	}
diff --git a/standard_renderer.go b/standard_renderer.go
index f81920d..284ad32 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -44,9 +44,6 @@ type standardRenderer struct {
 	// essentially whether or not we're using the full size of the terminal
 	altScreenActive bool
 
-	// whether or not we're currently using bracketed paste
-	bpActive bool
-
 	// renderer dimensions; usually the size of the window
 	width  int
 	height int
@@ -393,76 +390,6 @@ func (r *standardRenderer) hideCursor() {
 	r.execute(ansi.HideCursor)
 }
 
-func (r *standardRenderer) enableMouseCellMotion() {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	r.execute(ansi.EnableMouseCellMotion)
-}
-
-func (r *standardRenderer) disableMouseCellMotion() {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	r.execute(ansi.DisableMouseCellMotion)
-}
-
-func (r *standardRenderer) enableMouseAllMotion() {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	r.execute(ansi.EnableMouseAllMotion)
-}
-
-func (r *standardRenderer) disableMouseAllMotion() {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	r.execute(ansi.DisableMouseAllMotion)
-}
-
-func (r *standardRenderer) enableMouseSGRMode() {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	r.execute(ansi.EnableMouseSgrExt)
-}
-
-func (r *standardRenderer) disableMouseSGRMode() {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	r.execute(ansi.DisableMouseSgrExt)
-}
-
-func (r *standardRenderer) enableBracketedPaste() {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	r.execute(ansi.EnableBracketedPaste)
-	r.bpActive = true
-}
-
-func (r *standardRenderer) disableBracketedPaste() {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	r.execute(ansi.DisableBracketedPaste)
-	r.bpActive = false
-}
-
-func (r *standardRenderer) bracketedPasteActive() bool {
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
-	return r.bpActive
-}
-
-// setWindowTitle sets the terminal window title.
-func (r *standardRenderer) setWindowTitle(title string) {
-	r.execute(ansi.SetWindowTitle(title))
-}
-
 // setIgnoredLines specifies lines not to be touched by the standard Bubble Tea
 // renderer.
 func (r *standardRenderer) setIgnoredLines(from int, to int) {
diff --git a/table.go b/table.go
new file mode 100644
index 0000000..678388c
--- /dev/null
+++ b/table.go
@@ -0,0 +1,391 @@
+package tea
+
+import (
+	"strconv"
+
+	"github.com/charmbracelet/x/ansi"
+)
+
+// buildKeysTable builds a table of key sequences and their corresponding key
+// events based on the VT100/VT200, XTerm, and Urxvt terminal specs.
+// TODO: Use flags?
+func buildKeysTable(flags int, term string) map[string]Key {
+	nul := Key{Runes: []rune{' '}, Type: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space
+	if flags&_FlagCtrlAt != 0 {
+		nul = Key{Runes: []rune{'@'}, Mod: ModCtrl}
+	}
+
+	tab := Key{Type: KeyTab} // ctrl+i or tab
+	if flags&_FlagCtrlI != 0 {
+		tab = Key{Runes: []rune{'i'}, Mod: ModCtrl}
+	}
+
+	enter := Key{Type: KeyEnter} // ctrl+m or enter
+	if flags&_FlagCtrlM != 0 {
+		enter = Key{Runes: []rune{'m'}, Mod: ModCtrl}
+	}
+
+	esc := Key{Type: KeyEscape} // ctrl+[ or escape
+	if flags&_FlagCtrlOpenBracket != 0 {
+		esc = Key{Runes: []rune{'['}, Mod: ModCtrl} // ctrl+[ or escape
+	}
+
+	del := Key{Type: KeyBackspace}
+	if flags&_FlagBackspace != 0 {
+		del.Type = KeyDelete
+	}
+
+	find := Key{Type: KeyHome}
+	if flags&_FlagFind != 0 {
+		find.Type = KeyFind
+	}
+
+	sel := Key{Type: KeyEnd}
+	if flags&_FlagSelect != 0 {
+		sel.Type = KeySelect
+	}
+
+	// The following is a table of key sequences and their corresponding key
+	// events based on the VT100/VT200 terminal specs.
+	//
+	// See: https://vt100.net/docs/vt100-ug/chapter3.html#S3.2
+	// See: https://vt100.net/docs/vt220-rm/chapter3.html
+	//
+	// XXX: These keys may be overwritten by other options like XTerm or
+	// Terminfo.
+	table := map[string]Key{
+		// C0 control characters
+		string(byte(ansi.NUL)): nul,
+		string(byte(ansi.SOH)): {Runes: []rune{'a'}, Mod: ModCtrl},
+		string(byte(ansi.STX)): {Runes: []rune{'b'}, Mod: ModCtrl},
+		string(byte(ansi.ETX)): {Runes: []rune{'c'}, Mod: ModCtrl},
+		string(byte(ansi.EOT)): {Runes: []rune{'d'}, Mod: ModCtrl},
+		string(byte(ansi.ENQ)): {Runes: []rune{'e'}, Mod: ModCtrl},
+		string(byte(ansi.ACK)): {Runes: []rune{'f'}, Mod: ModCtrl},
+		string(byte(ansi.BEL)): {Runes: []rune{'g'}, Mod: ModCtrl},
+		string(byte(ansi.BS)):  {Runes: []rune{'h'}, Mod: ModCtrl},
+		string(byte(ansi.HT)):  tab,
+		string(byte(ansi.LF)):  {Runes: []rune{'j'}, Mod: ModCtrl},
+		string(byte(ansi.VT)):  {Runes: []rune{'k'}, Mod: ModCtrl},
+		string(byte(ansi.FF)):  {Runes: []rune{'l'}, Mod: ModCtrl},
+		string(byte(ansi.CR)):  enter,
+		string(byte(ansi.SO)):  {Runes: []rune{'n'}, Mod: ModCtrl},
+		string(byte(ansi.SI)):  {Runes: []rune{'o'}, Mod: ModCtrl},
+		string(byte(ansi.DLE)): {Runes: []rune{'p'}, Mod: ModCtrl},
+		string(byte(ansi.DC1)): {Runes: []rune{'q'}, Mod: ModCtrl},
+		string(byte(ansi.DC2)): {Runes: []rune{'r'}, Mod: ModCtrl},
+		string(byte(ansi.DC3)): {Runes: []rune{'s'}, Mod: ModCtrl},
+		string(byte(ansi.DC4)): {Runes: []rune{'t'}, Mod: ModCtrl},
+		string(byte(ansi.NAK)): {Runes: []rune{'u'}, Mod: ModCtrl},
+		string(byte(ansi.SYN)): {Runes: []rune{'v'}, Mod: ModCtrl},
+		string(byte(ansi.ETB)): {Runes: []rune{'w'}, Mod: ModCtrl},
+		string(byte(ansi.CAN)): {Runes: []rune{'x'}, Mod: ModCtrl},
+		string(byte(ansi.EM)):  {Runes: []rune{'y'}, Mod: ModCtrl},
+		string(byte(ansi.SUB)): {Runes: []rune{'z'}, Mod: ModCtrl},
+		string(byte(ansi.ESC)): esc,
+		string(byte(ansi.FS)):  {Runes: []rune{'\\'}, Mod: ModCtrl},
+		string(byte(ansi.GS)):  {Runes: []rune{']'}, Mod: ModCtrl},
+		string(byte(ansi.RS)):  {Runes: []rune{'^'}, Mod: ModCtrl},
+		string(byte(ansi.US)):  {Runes: []rune{'_'}, Mod: ModCtrl},
+
+		// Special keys in G0
+		string(byte(ansi.SP)):  {Type: KeySpace, Runes: []rune{' '}},
+		string(byte(ansi.DEL)): del,
+
+		// Special keys
+
+		"\x1b[Z": {Type: KeyTab, Mod: ModShift},
+
+		"\x1b[1~": find,
+		"\x1b[2~": {Type: KeyInsert},
+		"\x1b[3~": {Type: KeyDelete},
+		"\x1b[4~": sel,
+		"\x1b[5~": {Type: KeyPgUp},
+		"\x1b[6~": {Type: KeyPgDown},
+		"\x1b[7~": {Type: KeyHome},
+		"\x1b[8~": {Type: KeyEnd},
+
+		// Normal mode
+		"\x1b[A": {Type: KeyUp},
+		"\x1b[B": {Type: KeyDown},
+		"\x1b[C": {Type: KeyRight},
+		"\x1b[D": {Type: KeyLeft},
+		"\x1b[E": {Type: KeyBegin},
+		"\x1b[F": {Type: KeyEnd},
+		"\x1b[H": {Type: KeyHome},
+		"\x1b[P": {Type: KeyF1},
+		"\x1b[Q": {Type: KeyF2},
+		"\x1b[R": {Type: KeyF3},
+		"\x1b[S": {Type: KeyF4},
+
+		// Application Cursor Key Mode (DECCKM)
+		"\x1bOA": {Type: KeyUp},
+		"\x1bOB": {Type: KeyDown},
+		"\x1bOC": {Type: KeyRight},
+		"\x1bOD": {Type: KeyLeft},
+		"\x1bOE": {Type: KeyBegin},
+		"\x1bOF": {Type: KeyEnd},
+		"\x1bOH": {Type: KeyHome},
+		"\x1bOP": {Type: KeyF1},
+		"\x1bOQ": {Type: KeyF2},
+		"\x1bOR": {Type: KeyF3},
+		"\x1bOS": {Type: KeyF4},
+
+		// Keypad Application Mode (DECKPAM)
+
+		"\x1bOM": {Type: KeyKpEnter},
+		"\x1bOX": {Type: KeyKpEqual},
+		"\x1bOj": {Type: KeyKpMultiply},
+		"\x1bOk": {Type: KeyKpPlus},
+		"\x1bOl": {Type: KeyKpComma},
+		"\x1bOm": {Type: KeyKpMinus},
+		"\x1bOn": {Type: KeyKpDecimal},
+		"\x1bOo": {Type: KeyKpDivide},
+		"\x1bOp": {Type: KeyKp0},
+		"\x1bOq": {Type: KeyKp1},
+		"\x1bOr": {Type: KeyKp2},
+		"\x1bOs": {Type: KeyKp3},
+		"\x1bOt": {Type: KeyKp4},
+		"\x1bOu": {Type: KeyKp5},
+		"\x1bOv": {Type: KeyKp6},
+		"\x1bOw": {Type: KeyKp7},
+		"\x1bOx": {Type: KeyKp8},
+		"\x1bOy": {Type: KeyKp9},
+
+		// Function keys
+
+		"\x1b[11~": {Type: KeyF1},
+		"\x1b[12~": {Type: KeyF2},
+		"\x1b[13~": {Type: KeyF3},
+		"\x1b[14~": {Type: KeyF4},
+		"\x1b[15~": {Type: KeyF5},
+		"\x1b[17~": {Type: KeyF6},
+		"\x1b[18~": {Type: KeyF7},
+		"\x1b[19~": {Type: KeyF8},
+		"\x1b[20~": {Type: KeyF9},
+		"\x1b[21~": {Type: KeyF10},
+		"\x1b[23~": {Type: KeyF11},
+		"\x1b[24~": {Type: KeyF12},
+		"\x1b[25~": {Type: KeyF13},
+		"\x1b[26~": {Type: KeyF14},
+		"\x1b[28~": {Type: KeyF15},
+		"\x1b[29~": {Type: KeyF16},
+		"\x1b[31~": {Type: KeyF17},
+		"\x1b[32~": {Type: KeyF18},
+		"\x1b[33~": {Type: KeyF19},
+		"\x1b[34~": {Type: KeyF20},
+	}
+
+	// CSI ~ sequence keys
+	csiTildeKeys := map[string]Key{
+		"1": find, "2": {Type: KeyInsert},
+		"3": {Type: KeyDelete}, "4": sel,
+		"5": {Type: KeyPgUp}, "6": {Type: KeyPgDown},
+		"7": {Type: KeyHome}, "8": {Type: KeyEnd},
+		// There are no 9 and 10 keys
+		"11": {Type: KeyF1}, "12": {Type: KeyF2},
+		"13": {Type: KeyF3}, "14": {Type: KeyF4},
+		"15": {Type: KeyF5}, "17": {Type: KeyF6},
+		"18": {Type: KeyF7}, "19": {Type: KeyF8},
+		"20": {Type: KeyF9}, "21": {Type: KeyF10},
+		"23": {Type: KeyF11}, "24": {Type: KeyF12},
+		"25": {Type: KeyF13}, "26": {Type: KeyF14},
+		"28": {Type: KeyF15}, "29": {Type: KeyF16},
+		"31": {Type: KeyF17}, "32": {Type: KeyF18},
+		"33": {Type: KeyF19}, "34": {Type: KeyF20},
+	}
+
+	// URxvt keys
+	// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
+	table["\x1b[a"] = Key{Type: KeyUp, Mod: ModShift}
+	table["\x1b[b"] = Key{Type: KeyDown, Mod: ModShift}
+	table["\x1b[c"] = Key{Type: KeyRight, Mod: ModShift}
+	table["\x1b[d"] = Key{Type: KeyLeft, Mod: ModShift}
+	table["\x1bOa"] = Key{Type: KeyUp, Mod: ModCtrl}
+	table["\x1bOb"] = Key{Type: KeyDown, Mod: ModCtrl}
+	table["\x1bOc"] = Key{Type: KeyRight, Mod: ModCtrl}
+	table["\x1bOd"] = Key{Type: KeyLeft, Mod: ModCtrl}
+	// TODO: invistigate if shift-ctrl arrow keys collide with DECCKM keys i.e.
+	// "\x1bOA", "\x1bOB", "\x1bOC", "\x1bOD"
+
+	// URxvt modifier CSI ~ keys
+	for k, v := range csiTildeKeys {
+		key := v
+		// Normal (no modifier) already defined part of VT100/VT200
+		// Shift modifier
+		key.Mod = ModShift
+		table["\x1b["+k+"$"] = key
+		// Ctrl modifier
+		key.Mod = ModCtrl
+		table["\x1b["+k+"^"] = key
+		// Shift-Ctrl modifier
+		key.Mod = ModShift | ModCtrl
+		table["\x1b["+k+"@"] = key
+	}
+
+	// URxvt F keys
+	// Note: Shift + F1-F10 generates F11-F20.
+	// This means Shift + F1 and Shift + F2 will generate F11 and F12, the same
+	// applies to Ctrl + Shift F1 & F2.
+	//
+	// P.S. Don't like this? Blame URxvt, configure your terminal to use
+	// different escapes like XTerm, or switch to a better terminal ¯\_(ツ)_/¯
+	//
+	// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
+	table["\x1b[23$"] = Key{Type: KeyF11, Mod: ModShift}
+	table["\x1b[24$"] = Key{Type: KeyF12, Mod: ModShift}
+	table["\x1b[25$"] = Key{Type: KeyF13, Mod: ModShift}
+	table["\x1b[26$"] = Key{Type: KeyF14, Mod: ModShift}
+	table["\x1b[28$"] = Key{Type: KeyF15, Mod: ModShift}
+	table["\x1b[29$"] = Key{Type: KeyF16, Mod: ModShift}
+	table["\x1b[31$"] = Key{Type: KeyF17, Mod: ModShift}
+	table["\x1b[32$"] = Key{Type: KeyF18, Mod: ModShift}
+	table["\x1b[33$"] = Key{Type: KeyF19, Mod: ModShift}
+	table["\x1b[34$"] = Key{Type: KeyF20, Mod: ModShift}
+	table["\x1b[11^"] = Key{Type: KeyF1, Mod: ModCtrl}
+	table["\x1b[12^"] = Key{Type: KeyF2, Mod: ModCtrl}
+	table["\x1b[13^"] = Key{Type: KeyF3, Mod: ModCtrl}
+	table["\x1b[14^"] = Key{Type: KeyF4, Mod: ModCtrl}
+	table["\x1b[15^"] = Key{Type: KeyF5, Mod: ModCtrl}
+	table["\x1b[17^"] = Key{Type: KeyF6, Mod: ModCtrl}
+	table["\x1b[18^"] = Key{Type: KeyF7, Mod: ModCtrl}
+	table["\x1b[19^"] = Key{Type: KeyF8, Mod: ModCtrl}
+	table["\x1b[20^"] = Key{Type: KeyF9, Mod: ModCtrl}
+	table["\x1b[21^"] = Key{Type: KeyF10, Mod: ModCtrl}
+	table["\x1b[23^"] = Key{Type: KeyF11, Mod: ModCtrl}
+	table["\x1b[24^"] = Key{Type: KeyF12, Mod: ModCtrl}
+	table["\x1b[25^"] = Key{Type: KeyF13, Mod: ModCtrl}
+	table["\x1b[26^"] = Key{Type: KeyF14, Mod: ModCtrl}
+	table["\x1b[28^"] = Key{Type: KeyF15, Mod: ModCtrl}
+	table["\x1b[29^"] = Key{Type: KeyF16, Mod: ModCtrl}
+	table["\x1b[31^"] = Key{Type: KeyF17, Mod: ModCtrl}
+	table["\x1b[32^"] = Key{Type: KeyF18, Mod: ModCtrl}
+	table["\x1b[33^"] = Key{Type: KeyF19, Mod: ModCtrl}
+	table["\x1b[34^"] = Key{Type: KeyF20, Mod: ModCtrl}
+	table["\x1b[23@"] = Key{Type: KeyF11, Mod: ModShift | ModCtrl}
+	table["\x1b[24@"] = Key{Type: KeyF12, Mod: ModShift | ModCtrl}
+	table["\x1b[25@"] = Key{Type: KeyF13, Mod: ModShift | ModCtrl}
+	table["\x1b[26@"] = Key{Type: KeyF14, Mod: ModShift | ModCtrl}
+	table["\x1b[28@"] = Key{Type: KeyF15, Mod: ModShift | ModCtrl}
+	table["\x1b[29@"] = Key{Type: KeyF16, Mod: ModShift | ModCtrl}
+	table["\x1b[31@"] = Key{Type: KeyF17, Mod: ModShift | ModCtrl}
+	table["\x1b[32@"] = Key{Type: KeyF18, Mod: ModShift | ModCtrl}
+	table["\x1b[33@"] = Key{Type: KeyF19, Mod: ModShift | ModCtrl}
+	table["\x1b[34@"] = Key{Type: KeyF20, Mod: ModShift | ModCtrl}
+
+	// Register Alt + <key> combinations
+	// XXX: this must come after URxvt but before XTerm keys to register URxvt
+	// keys with alt modifier
+	tmap := map[string]Key{}
+	for seq, key := range table {
+		key := key
+		key.Mod |= ModAlt
+		tmap["\x1b"+seq] = key
+	}
+	for seq, key := range tmap {
+		table[seq] = key
+	}
+
+	// XTerm modifiers
+	// These are offset by 1 to be compatible with our Mod type.
+	// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-PC-Style-Function-Keys
+	modifiers := []KeyMod{
+		ModShift,                              // 1
+		ModAlt,                                // 2
+		ModShift | ModAlt,                     // 3
+		ModCtrl,                               // 4
+		ModShift | ModCtrl,                    // 5
+		ModAlt | ModCtrl,                      // 6
+		ModShift | ModAlt | ModCtrl,           // 7
+		ModMeta,                               // 8
+		ModMeta | ModShift,                    // 9
+		ModMeta | ModAlt,                      // 10
+		ModMeta | ModShift | ModAlt,           // 11
+		ModMeta | ModCtrl,                     // 12
+		ModMeta | ModShift | ModCtrl,          // 13
+		ModMeta | ModAlt | ModCtrl,            // 14
+		ModMeta | ModShift | ModAlt | ModCtrl, // 15
+	}
+
+	// SS3 keypad function keys
+	ss3FuncKeys := map[string]Key{
+		// These are defined in XTerm
+		// Taken from Foot keymap.h and XTerm modifyOtherKeys
+		// https://codeberg.org/dnkl/foot/src/branch/master/keymap.h
+		"M": {Type: KeyKpEnter}, "X": {Type: KeyKpEqual},
+		"j": {Type: KeyKpMultiply}, "k": {Type: KeyKpPlus},
+		"l": {Type: KeyKpComma}, "m": {Type: KeyKpMinus},
+		"n": {Type: KeyKpDecimal}, "o": {Type: KeyKpDivide},
+		"p": {Type: KeyKp0}, "q": {Type: KeyKp1},
+		"r": {Type: KeyKp2}, "s": {Type: KeyKp3},
+		"t": {Type: KeyKp4}, "u": {Type: KeyKp5},
+		"v": {Type: KeyKp6}, "w": {Type: KeyKp7},
+		"x": {Type: KeyKp8}, "y": {Type: KeyKp9},
+	}
+
+	// XTerm keys
+	csiFuncKeys := map[string]Key{
+		"A": {Type: KeyUp}, "B": {Type: KeyDown},
+		"C": {Type: KeyRight}, "D": {Type: KeyLeft},
+		"E": {Type: KeyBegin}, "F": {Type: KeyEnd},
+		"H": {Type: KeyHome}, "P": {Type: KeyF1},
+		"Q": {Type: KeyF2}, "R": {Type: KeyF3},
+		"S": {Type: KeyF4},
+	}
+
+	// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
+	modifyOtherKeys := map[int]Key{
+		ansi.BS:  {Type: KeyBackspace},
+		ansi.HT:  {Type: KeyTab},
+		ansi.CR:  {Type: KeyEnter},
+		ansi.ESC: {Type: KeyEscape},
+		ansi.DEL: {Type: KeyBackspace},
+	}
+
+	for _, m := range modifiers {
+		// XTerm modifier offset +1
+		xtermMod := strconv.Itoa(int(m) + 1)
+
+		//  CSI 1 ; <modifier> <func>
+		for k, v := range csiFuncKeys {
+			// Functions always have a leading 1 param
+			seq := "\x1b[1;" + xtermMod + k
+			key := v
+			key.Mod = m
+			table[seq] = key
+		}
+		// SS3 <modifier> <func>
+		for k, v := range ss3FuncKeys {
+			seq := "\x1bO" + xtermMod + k
+			key := v
+			key.Mod = m
+			table[seq] = key
+		}
+		//  CSI <number> ; <modifier> ~
+		for k, v := range csiTildeKeys {
+			seq := "\x1b[" + k + ";" + xtermMod + "~"
+			key := v
+			key.Mod = m
+			table[seq] = key
+		}
+		// CSI 27 ; <modifier> ; <code> ~
+		for k, v := range modifyOtherKeys {
+			code := strconv.Itoa(k)
+			seq := "\x1b[27;" + xtermMod + ";" + code + "~"
+			key := v
+			key.Mod = m
+			table[seq] = key
+		}
+	}
+
+	// Register terminfo keys
+	// XXX: this might override keys already registered in table
+	if flags&_FlagTerminfo != 0 {
+		titable := buildTerminfoKeys(flags, term)
+		for seq, key := range titable {
+			table[seq] = key
+		}
+	}
+
+	return table
+}
diff --git a/tea.go b/tea.go
index 62cd641..d9b95a6 100644
--- a/tea.go
+++ b/tea.go
@@ -21,8 +21,8 @@ import (
 	"sync/atomic"
 	"syscall"
 
+	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/term"
-	"github.com/muesli/cancelreader"
 	"golang.org/x/sync/errgroup"
 )
 
@@ -159,14 +159,14 @@ type Program struct {
 	// ttyInput is null if input is not a TTY.
 	ttyInput              term.File
 	previousTtyInputState *term.State
-	cancelReader          cancelreader.CancelReader
+	inputReader           *driver
 	readLoopDone          chan struct{}
 
 	// was the altscreen active before releasing the terminal?
 	altScreenWasActive bool
 	ignoreSignals      uint32
 
-	bpWasActive bool // was the bracketed paste mode active before releasing the terminal?
+	bpActive bool // was the bracketed paste mode active before releasing the terminal?
 
 	filter func(Model, Msg) Msg
 
@@ -321,9 +321,9 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
 }
 
 func (p *Program) disableMouse() {
-	p.renderer.disableMouseCellMotion()
-	p.renderer.disableMouseAllMotion()
-	p.renderer.disableMouseSGRMode()
+	p.renderer.execute(ansi.DisableMouseCellMotion)
+	p.renderer.execute(ansi.DisableMouseAllMotion)
+	p.renderer.execute(ansi.DisableMouseSgrExt)
 }
 
 // eventLoop is the central message loop. It receives and handles the default
@@ -368,12 +368,12 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 			case enableMouseCellMotionMsg, enableMouseAllMotionMsg:
 				switch msg.(type) {
 				case enableMouseCellMotionMsg:
-					p.renderer.enableMouseCellMotion()
+					p.renderer.execute(ansi.EnableMouseCellMotion)
 				case enableMouseAllMotionMsg:
-					p.renderer.enableMouseAllMotion()
+					p.renderer.execute(ansi.EnableMouseAllMotion)
 				}
 				// mouse mode (1006) is a no-op if the terminal doesn't support it.
-				p.renderer.enableMouseSGRMode()
+				p.renderer.execute(ansi.EnableMouseSgrExt)
 
 			case disableMouseMsg:
 				p.disableMouse()
@@ -385,10 +385,12 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				p.renderer.hideCursor()
 
 			case enableBracketedPasteMsg:
-				p.renderer.enableBracketedPaste()
+				p.renderer.execute(ansi.EnableBracketedPaste)
+				p.bpActive = true
 
 			case disableBracketedPasteMsg:
-				p.renderer.disableBracketedPaste()
+				p.renderer.execute(ansi.DisableBracketedPaste)
+				p.bpActive = false
 
 			case execMsg:
 				// NB: this blocks.
@@ -514,40 +516,51 @@ func (p *Program) Run() (Model, error) {
 		}()
 	}
 
+	// Check if output is a TTY before entering raw mode, hiding the cursor and
+	// so on.
+	if err := p.initTerminal(); err != nil {
+		return p.initialModel, err
+	}
+
 	// If no renderer is set use the standard one.
 	if p.renderer == nil {
 		p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor), p.fps)
 	}
 
-	// Check if output is a TTY before entering raw mode, hiding the cursor and
-	// so on.
-	if err := p.initTerminal(); err != nil {
-		return p.initialModel, err
+	// Init the input reader and initial model.
+	model := p.initialModel
+	if p.input != nil {
+		if err := p.initInputReader(); err != nil {
+			return model, err
+		}
 	}
 
+	// Hide the cursor before starting the renderer.
+	p.renderer.hideCursor()
+
 	// Honor program startup options.
 	if p.startupTitle != "" {
-		p.renderer.setWindowTitle(p.startupTitle)
+		p.renderer.execute(ansi.SetWindowTitle(p.startupTitle))
 	}
 	if p.startupOptions&withAltScreen != 0 {
 		p.renderer.enterAltScreen()
 	}
 	if p.startupOptions&withoutBracketedPaste == 0 {
-		p.renderer.enableBracketedPaste()
+		p.renderer.execute(ansi.EnableBracketedPaste)
+		p.bpActive = true
 	}
 	if p.startupOptions&withMouseCellMotion != 0 {
-		p.renderer.enableMouseCellMotion()
-		p.renderer.enableMouseSGRMode()
+		p.renderer.execute(ansi.EnableMouseCellMotion)
+		p.renderer.execute(ansi.EnableMouseSgrExt)
 	} else if p.startupOptions&withMouseAllMotion != 0 {
-		p.renderer.enableMouseAllMotion()
-		p.renderer.enableMouseSGRMode()
+		p.renderer.execute(ansi.EnableMouseAllMotion)
+		p.renderer.execute(ansi.EnableMouseSgrExt)
 	}
 
 	// Start the renderer.
 	p.renderer.start()
 
 	// Initialize the program.
-	model := p.initialModel
 	if initCmd := model.Init(); initCmd != nil {
 		ch := make(chan struct{})
 		handlers.add(ch)
@@ -565,13 +578,6 @@ func (p *Program) Run() (Model, error) {
 	// Render the initial view.
 	p.renderer.write(model.View())
 
-	// Subscribe to user input.
-	if p.input != nil {
-		if err := p.initCancelReader(); err != nil {
-			return model, err
-		}
-	}
-
 	// Handle resize events.
 	handlers.add(p.handleResize())
 
@@ -592,12 +598,12 @@ func (p *Program) Run() (Model, error) {
 	p.cancel()
 
 	// Check if the cancel reader has been setup before waiting and closing.
-	if p.cancelReader != nil {
+	if p.inputReader != nil {
 		// Wait for input loop to finish.
-		if p.cancelReader.Cancel() {
+		if p.inputReader.Cancel() {
 			p.waitForReadLoop()
 		}
-		_ = p.cancelReader.Close()
+		_ = p.inputReader.Close()
 	}
 
 	// Wait for all handlers to finish.
@@ -684,8 +690,8 @@ func (p *Program) shutdown(kill bool) {
 // reader. You can return control to the Program with RestoreTerminal.
 func (p *Program) ReleaseTerminal() error {
 	atomic.StoreUint32(&p.ignoreSignals, 1)
-	if p.cancelReader != nil {
-		p.cancelReader.Cancel()
+	if p.inputReader != nil {
+		p.inputReader.Cancel()
 	}
 
 	p.waitForReadLoop()
@@ -693,7 +699,6 @@ func (p *Program) ReleaseTerminal() error {
 	if p.renderer != nil {
 		p.renderer.stop()
 		p.altScreenWasActive = p.renderer.altScreen()
-		p.bpWasActive = p.renderer.bracketedPasteActive()
 	}
 
 	return p.restoreTerminalState()
@@ -708,7 +713,7 @@ func (p *Program) RestoreTerminal() error {
 	if err := p.initTerminal(); err != nil {
 		return err
 	}
-	if err := p.initCancelReader(); err != nil {
+	if err := p.initInputReader(); err != nil {
 		return err
 	}
 	if p.altScreenWasActive {
@@ -719,9 +724,9 @@ func (p *Program) RestoreTerminal() error {
 	}
 	if p.renderer != nil {
 		p.renderer.start()
-	}
-	if p.bpWasActive {
-		p.renderer.enableBracketedPaste()
+		p.renderer.hideCursor()
+		p.renderer.execute(ansi.EnableBracketedPaste)
+		p.bpActive = true
 	}
 
 	// If the output is a terminal, it may have been resized while another
diff --git a/termcap.go b/termcap.go
new file mode 100644
index 0000000..b2d3191
--- /dev/null
+++ b/termcap.go
@@ -0,0 +1,54 @@
+package tea
+
+import (
+	"bytes"
+	"encoding/hex"
+	"strings"
+)
+
+// TermcapMsg represents a Termcap response event. Termcap responses are
+// generated by the terminal in response to RequestTermcap (XTGETTCAP)
+// requests.
+//
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+type TermcapMsg string
+
+func parseTermcap(data []byte) TermcapMsg {
+	// XTGETTCAP
+	if len(data) == 0 {
+		return TermcapMsg("")
+	}
+
+	var tc strings.Builder
+	split := bytes.Split(data, []byte{';'})
+	for _, s := range split {
+		parts := bytes.SplitN(s, []byte{'='}, 2)
+		if len(parts) == 0 {
+			return TermcapMsg("")
+		}
+
+		name, err := hex.DecodeString(string(parts[0]))
+		if err != nil || len(name) == 0 {
+			continue
+		}
+
+		var value []byte
+		if len(parts) > 1 {
+			value, err = hex.DecodeString(string(parts[1]))
+			if err != nil {
+				continue
+			}
+		}
+
+		if tc.Len() > 0 {
+			tc.WriteByte(';')
+		}
+		tc.WriteString(string(name))
+		if len(value) > 0 {
+			tc.WriteByte('=')
+			tc.WriteString(string(value))
+		}
+	}
+
+	return TermcapMsg(tc.String())
+}
diff --git a/terminfo.go b/terminfo.go
new file mode 100644
index 0000000..e4be0dd
--- /dev/null
+++ b/terminfo.go
@@ -0,0 +1,277 @@
+package tea
+
+import (
+	"strings"
+
+	"github.com/xo/terminfo"
+)
+
+func buildTerminfoKeys(flags int, term string) map[string]Key {
+	table := make(map[string]Key)
+	ti, _ := terminfo.Load(term)
+	if ti == nil {
+		return table
+	}
+
+	tiTable := defaultTerminfoKeys(flags)
+
+	// Default keys
+	for name, seq := range ti.StringCapsShort() {
+		if !strings.HasPrefix(name, "k") || len(seq) == 0 {
+			continue
+		}
+
+		if k, ok := tiTable[name]; ok {
+			table[string(seq)] = k
+		}
+	}
+
+	// Extended keys
+	for name, seq := range ti.ExtStringCapsShort() {
+		if !strings.HasPrefix(name, "k") || len(seq) == 0 {
+			continue
+		}
+
+		if k, ok := tiTable[name]; ok {
+			table[string(seq)] = k
+		}
+	}
+
+	return table
+}
+
+// This returns a map of terminfo keys to key events. It's a mix of ncurses
+// terminfo default and user-defined key capabilities.
+// Upper-case caps that are defined in the default terminfo database are
+//   - kNXT
+//   - kPRV
+//   - kHOM
+//   - kEND
+//   - kDC
+//   - kIC
+//   - kLFT
+//   - kRIT
+//
+// See https://man7.org/linux/man-pages/man5/terminfo.5.html
+// See https://github.com/mirror/ncurses/blob/master/include/Caps-ncurses
+func defaultTerminfoKeys(flags int) map[string]Key {
+	keys := map[string]Key{
+		"kcuu1": {Type: KeyUp},
+		"kUP":   {Type: KeyUp, Mod: ModShift},
+		"kUP3":  {Type: KeyUp, Mod: ModAlt},
+		"kUP4":  {Type: KeyUp, Mod: ModShift | ModAlt},
+		"kUP5":  {Type: KeyUp, Mod: ModCtrl},
+		"kUP6":  {Type: KeyUp, Mod: ModShift | ModCtrl},
+		"kUP7":  {Type: KeyUp, Mod: ModAlt | ModCtrl},
+		"kUP8":  {Type: KeyUp, Mod: ModShift | ModAlt | ModCtrl},
+		"kcud1": {Type: KeyDown},
+		"kDN":   {Type: KeyDown, Mod: ModShift},
+		"kDN3":  {Type: KeyDown, Mod: ModAlt},
+		"kDN4":  {Type: KeyDown, Mod: ModShift | ModAlt},
+		"kDN5":  {Type: KeyDown, Mod: ModCtrl},
+		"kDN7":  {Type: KeyDown, Mod: ModAlt | ModCtrl},
+		"kDN6":  {Type: KeyDown, Mod: ModShift | ModCtrl},
+		"kDN8":  {Type: KeyDown, Mod: ModShift | ModAlt | ModCtrl},
+		"kcub1": {Type: KeyLeft},
+		"kLFT":  {Type: KeyLeft, Mod: ModShift},
+		"kLFT3": {Type: KeyLeft, Mod: ModAlt},
+		"kLFT4": {Type: KeyLeft, Mod: ModShift | ModAlt},
+		"kLFT5": {Type: KeyLeft, Mod: ModCtrl},
+		"kLFT6": {Type: KeyLeft, Mod: ModShift | ModCtrl},
+		"kLFT7": {Type: KeyLeft, Mod: ModAlt | ModCtrl},
+		"kLFT8": {Type: KeyLeft, Mod: ModShift | ModAlt | ModCtrl},
+		"kcuf1": {Type: KeyRight},
+		"kRIT":  {Type: KeyRight, Mod: ModShift},
+		"kRIT3": {Type: KeyRight, Mod: ModAlt},
+		"kRIT4": {Type: KeyRight, Mod: ModShift | ModAlt},
+		"kRIT5": {Type: KeyRight, Mod: ModCtrl},
+		"kRIT6": {Type: KeyRight, Mod: ModShift | ModCtrl},
+		"kRIT7": {Type: KeyRight, Mod: ModAlt | ModCtrl},
+		"kRIT8": {Type: KeyRight, Mod: ModShift | ModAlt | ModCtrl},
+		"kich1": {Type: KeyInsert},
+		"kIC":   {Type: KeyInsert, Mod: ModShift},
+		"kIC3":  {Type: KeyInsert, Mod: ModAlt},
+		"kIC4":  {Type: KeyInsert, Mod: ModShift | ModAlt},
+		"kIC5":  {Type: KeyInsert, Mod: ModCtrl},
+		"kIC6":  {Type: KeyInsert, Mod: ModShift | ModCtrl},
+		"kIC7":  {Type: KeyInsert, Mod: ModAlt | ModCtrl},
+		"kIC8":  {Type: KeyInsert, Mod: ModShift | ModAlt | ModCtrl},
+		"kdch1": {Type: KeyDelete},
+		"kDC":   {Type: KeyDelete, Mod: ModShift},
+		"kDC3":  {Type: KeyDelete, Mod: ModAlt},
+		"kDC4":  {Type: KeyDelete, Mod: ModShift | ModAlt},
+		"kDC5":  {Type: KeyDelete, Mod: ModCtrl},
+		"kDC6":  {Type: KeyDelete, Mod: ModShift | ModCtrl},
+		"kDC7":  {Type: KeyDelete, Mod: ModAlt | ModCtrl},
+		"kDC8":  {Type: KeyDelete, Mod: ModShift | ModAlt | ModCtrl},
+		"khome": {Type: KeyHome},
+		"kHOM":  {Type: KeyHome, Mod: ModShift},
+		"kHOM3": {Type: KeyHome, Mod: ModAlt},
+		"kHOM4": {Type: KeyHome, Mod: ModShift | ModAlt},
+		"kHOM5": {Type: KeyHome, Mod: ModCtrl},
+		"kHOM6": {Type: KeyHome, Mod: ModShift | ModCtrl},
+		"kHOM7": {Type: KeyHome, Mod: ModAlt | ModCtrl},
+		"kHOM8": {Type: KeyHome, Mod: ModShift | ModAlt | ModCtrl},
+		"kend":  {Type: KeyEnd},
+		"kEND":  {Type: KeyEnd, Mod: ModShift},
+		"kEND3": {Type: KeyEnd, Mod: ModAlt},
+		"kEND4": {Type: KeyEnd, Mod: ModShift | ModAlt},
+		"kEND5": {Type: KeyEnd, Mod: ModCtrl},
+		"kEND6": {Type: KeyEnd, Mod: ModShift | ModCtrl},
+		"kEND7": {Type: KeyEnd, Mod: ModAlt | ModCtrl},
+		"kEND8": {Type: KeyEnd, Mod: ModShift | ModAlt | ModCtrl},
+		"kpp":   {Type: KeyPgUp},
+		"kprv":  {Type: KeyPgUp},
+		"kPRV":  {Type: KeyPgUp, Mod: ModShift},
+		"kPRV3": {Type: KeyPgUp, Mod: ModAlt},
+		"kPRV4": {Type: KeyPgUp, Mod: ModShift | ModAlt},
+		"kPRV5": {Type: KeyPgUp, Mod: ModCtrl},
+		"kPRV6": {Type: KeyPgUp, Mod: ModShift | ModCtrl},
+		"kPRV7": {Type: KeyPgUp, Mod: ModAlt | ModCtrl},
+		"kPRV8": {Type: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl},
+		"knp":   {Type: KeyPgDown},
+		"knxt":  {Type: KeyPgDown},
+		"kNXT":  {Type: KeyPgDown, Mod: ModShift},
+		"kNXT3": {Type: KeyPgDown, Mod: ModAlt},
+		"kNXT4": {Type: KeyPgDown, Mod: ModShift | ModAlt},
+		"kNXT5": {Type: KeyPgDown, Mod: ModCtrl},
+		"kNXT6": {Type: KeyPgDown, Mod: ModShift | ModCtrl},
+		"kNXT7": {Type: KeyPgDown, Mod: ModAlt | ModCtrl},
+		"kNXT8": {Type: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl},
+
+		"kbs":  {Type: KeyBackspace},
+		"kcbt": {Type: KeyTab, Mod: ModShift},
+
+		// Function keys
+		// This only includes the first 12 function keys. The rest are treated
+		// as modifiers of the first 12.
+		// Take a look at XTerm modifyFunctionKeys
+		//
+		// XXX: To use unambiguous function keys, use fixterms or kitty clipboard.
+		//
+		// See https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyFunctionKeys
+		// See https://invisible-island.net/xterm/terminfo.html
+
+		"kf1":  {Type: KeyF1},
+		"kf2":  {Type: KeyF2},
+		"kf3":  {Type: KeyF3},
+		"kf4":  {Type: KeyF4},
+		"kf5":  {Type: KeyF5},
+		"kf6":  {Type: KeyF6},
+		"kf7":  {Type: KeyF7},
+		"kf8":  {Type: KeyF8},
+		"kf9":  {Type: KeyF9},
+		"kf10": {Type: KeyF10},
+		"kf11": {Type: KeyF11},
+		"kf12": {Type: KeyF12},
+		"kf13": {Type: KeyF1, Mod: ModShift},
+		"kf14": {Type: KeyF2, Mod: ModShift},
+		"kf15": {Type: KeyF3, Mod: ModShift},
+		"kf16": {Type: KeyF4, Mod: ModShift},
+		"kf17": {Type: KeyF5, Mod: ModShift},
+		"kf18": {Type: KeyF6, Mod: ModShift},
+		"kf19": {Type: KeyF7, Mod: ModShift},
+		"kf20": {Type: KeyF8, Mod: ModShift},
+		"kf21": {Type: KeyF9, Mod: ModShift},
+		"kf22": {Type: KeyF10, Mod: ModShift},
+		"kf23": {Type: KeyF11, Mod: ModShift},
+		"kf24": {Type: KeyF12, Mod: ModShift},
+		"kf25": {Type: KeyF1, Mod: ModCtrl},
+		"kf26": {Type: KeyF2, Mod: ModCtrl},
+		"kf27": {Type: KeyF3, Mod: ModCtrl},
+		"kf28": {Type: KeyF4, Mod: ModCtrl},
+		"kf29": {Type: KeyF5, Mod: ModCtrl},
+		"kf30": {Type: KeyF6, Mod: ModCtrl},
+		"kf31": {Type: KeyF7, Mod: ModCtrl},
+		"kf32": {Type: KeyF8, Mod: ModCtrl},
+		"kf33": {Type: KeyF9, Mod: ModCtrl},
+		"kf34": {Type: KeyF10, Mod: ModCtrl},
+		"kf35": {Type: KeyF11, Mod: ModCtrl},
+		"kf36": {Type: KeyF12, Mod: ModCtrl},
+		"kf37": {Type: KeyF1, Mod: ModShift | ModCtrl},
+		"kf38": {Type: KeyF2, Mod: ModShift | ModCtrl},
+		"kf39": {Type: KeyF3, Mod: ModShift | ModCtrl},
+		"kf40": {Type: KeyF4, Mod: ModShift | ModCtrl},
+		"kf41": {Type: KeyF5, Mod: ModShift | ModCtrl},
+		"kf42": {Type: KeyF6, Mod: ModShift | ModCtrl},
+		"kf43": {Type: KeyF7, Mod: ModShift | ModCtrl},
+		"kf44": {Type: KeyF8, Mod: ModShift | ModCtrl},
+		"kf45": {Type: KeyF9, Mod: ModShift | ModCtrl},
+		"kf46": {Type: KeyF10, Mod: ModShift | ModCtrl},
+		"kf47": {Type: KeyF11, Mod: ModShift | ModCtrl},
+		"kf48": {Type: KeyF12, Mod: ModShift | ModCtrl},
+		"kf49": {Type: KeyF1, Mod: ModAlt},
+		"kf50": {Type: KeyF2, Mod: ModAlt},
+		"kf51": {Type: KeyF3, Mod: ModAlt},
+		"kf52": {Type: KeyF4, Mod: ModAlt},
+		"kf53": {Type: KeyF5, Mod: ModAlt},
+		"kf54": {Type: KeyF6, Mod: ModAlt},
+		"kf55": {Type: KeyF7, Mod: ModAlt},
+		"kf56": {Type: KeyF8, Mod: ModAlt},
+		"kf57": {Type: KeyF9, Mod: ModAlt},
+		"kf58": {Type: KeyF10, Mod: ModAlt},
+		"kf59": {Type: KeyF11, Mod: ModAlt},
+		"kf60": {Type: KeyF12, Mod: ModAlt},
+		"kf61": {Type: KeyF1, Mod: ModShift | ModAlt},
+		"kf62": {Type: KeyF2, Mod: ModShift | ModAlt},
+		"kf63": {Type: KeyF3, Mod: ModShift | ModAlt},
+	}
+
+	// Preserve F keys from F13 to F63 instead of using them for F-keys
+	// modifiers.
+	if flags&_FlagFKeys != 0 {
+		keys["kf13"] = Key{Type: KeyF13}
+		keys["kf14"] = Key{Type: KeyF14}
+		keys["kf15"] = Key{Type: KeyF15}
+		keys["kf16"] = Key{Type: KeyF16}
+		keys["kf17"] = Key{Type: KeyF17}
+		keys["kf18"] = Key{Type: KeyF18}
+		keys["kf19"] = Key{Type: KeyF19}
+		keys["kf20"] = Key{Type: KeyF20}
+		keys["kf21"] = Key{Type: KeyF21}
+		keys["kf22"] = Key{Type: KeyF22}
+		keys["kf23"] = Key{Type: KeyF23}
+		keys["kf24"] = Key{Type: KeyF24}
+		keys["kf25"] = Key{Type: KeyF25}
+		keys["kf26"] = Key{Type: KeyF26}
+		keys["kf27"] = Key{Type: KeyF27}
+		keys["kf28"] = Key{Type: KeyF28}
+		keys["kf29"] = Key{Type: KeyF29}
+		keys["kf30"] = Key{Type: KeyF30}
+		keys["kf31"] = Key{Type: KeyF31}
+		keys["kf32"] = Key{Type: KeyF32}
+		keys["kf33"] = Key{Type: KeyF33}
+		keys["kf34"] = Key{Type: KeyF34}
+		keys["kf35"] = Key{Type: KeyF35}
+		keys["kf36"] = Key{Type: KeyF36}
+		keys["kf37"] = Key{Type: KeyF37}
+		keys["kf38"] = Key{Type: KeyF38}
+		keys["kf39"] = Key{Type: KeyF39}
+		keys["kf40"] = Key{Type: KeyF40}
+		keys["kf41"] = Key{Type: KeyF41}
+		keys["kf42"] = Key{Type: KeyF42}
+		keys["kf43"] = Key{Type: KeyF43}
+		keys["kf44"] = Key{Type: KeyF44}
+		keys["kf45"] = Key{Type: KeyF45}
+		keys["kf46"] = Key{Type: KeyF46}
+		keys["kf47"] = Key{Type: KeyF47}
+		keys["kf48"] = Key{Type: KeyF48}
+		keys["kf49"] = Key{Type: KeyF49}
+		keys["kf50"] = Key{Type: KeyF50}
+		keys["kf51"] = Key{Type: KeyF51}
+		keys["kf52"] = Key{Type: KeyF52}
+		keys["kf53"] = Key{Type: KeyF53}
+		keys["kf54"] = Key{Type: KeyF54}
+		keys["kf55"] = Key{Type: KeyF55}
+		keys["kf56"] = Key{Type: KeyF56}
+		keys["kf57"] = Key{Type: KeyF57}
+		keys["kf58"] = Key{Type: KeyF58}
+		keys["kf59"] = Key{Type: KeyF59}
+		keys["kf60"] = Key{Type: KeyF60}
+		keys["kf61"] = Key{Type: KeyF61}
+		keys["kf62"] = Key{Type: KeyF62}
+		keys["kf63"] = Key{Type: KeyF63}
+	}
+
+	return keys
+}
diff --git a/tty.go b/tty.go
index ed469ad..9ec3d2e 100644
--- a/tty.go
+++ b/tty.go
@@ -1,11 +1,14 @@
 package tea
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"io"
+	"strings"
 	"time"
 
+	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/term"
 	"github.com/muesli/cancelreader"
 )
@@ -23,19 +26,15 @@ func (p *Program) suspend() {
 }
 
 func (p *Program) initTerminal() error {
-	if err := p.initInput(); err != nil {
-		return err
-	}
-
-	p.renderer.hideCursor()
-	return nil
+	return p.initInput()
 }
 
 // restoreTerminalState restores the terminal to the state prior to running the
 // Bubble Tea program.
 func (p *Program) restoreTerminalState() error {
 	if p.renderer != nil {
-		p.renderer.disableBracketedPaste()
+		p.renderer.execute(ansi.DisableBracketedPaste)
+		p.bpActive = false
 		p.renderer.showCursor()
 		p.disableMouse()
 
@@ -65,24 +64,164 @@ func (p *Program) restoreInput() error {
 	return nil
 }
 
-// initCancelReader (re)commences reading inputs.
-func (p *Program) initCancelReader() error {
-	var err error
-	p.cancelReader, err = newInputReader(p.input)
+// initInputReader (re)commences reading inputs.
+func (p *Program) initInputReader() error {
+	var term string
+	for i := len(p.environ) - 1; i >= 0; i-- {
+		// We iterate backwards to find the last TERM variable set in the
+		// environment. This is because the last one is the one that will be
+		// used by the terminal.
+		parts := strings.SplitN(p.environ[i], "=", 2)
+		if len(parts) == 2 && parts[0] == "TERM" {
+			term = parts[1]
+			break
+		}
+	}
+
+	// Initialize the input reader.
+	// This need to be done after the terminal has been initialized and set to
+	// raw mode.
+	// On Windows, this will change the console mode to enable mouse and window
+	// events.
+	var flags int // TODO: make configurable through environment variables?
+	drv, err := newDriver(p.input, term, flags)
 	if err != nil {
-		return fmt.Errorf("error creating cancelreader: %w", err)
+		return err
 	}
 
+	p.inputReader = drv
 	p.readLoopDone = make(chan struct{})
 	go p.readLoop()
 
 	return nil
 }
 
+func readInputs(ctx context.Context, msgs chan<- Msg, reader *driver) error {
+	for {
+		events, err := reader.ReadEvents()
+		if err != nil {
+			return err
+		}
+
+		for _, msg := range events {
+			incomingMsgs := []Msg{msg}
+
+			// We need to translate new e types to deprecated ones to keep
+			// compatibility.
+			switch e := msg.(type) {
+			case PasteMsg:
+				var k KeyMsg
+				k.Paste = true
+				k.Runes = []rune(e)
+				incomingMsgs = append(incomingMsgs, k)
+			case KeyPressMsg:
+				k := KeyMsg{
+					Alt:   e.Mod.Contains(ModAlt),
+					Runes: e.Runes,
+					Type:  e.Type,
+				}
+
+				// Backwards compatibility for ctrl- and shift- keys
+				switch {
+				case e.Mod.Contains(ModCtrl | ModShift):
+					switch e.Type {
+					case KeyUp, KeyDown, KeyRight, KeyLeft:
+						k.Runes = nil
+						k.Type = KeyCtrlShiftUp - e.Type + KeyUp
+					case KeyHome, KeyEnd:
+						k.Runes = nil
+						k.Type = KeyCtrlShiftHome - e.Type + KeyHome
+					}
+				case e.Mod.Contains(ModCtrl):
+					switch e.Type {
+					case KeyRunes: // KeyRunes
+						switch r := e.Rune(); r {
+						case ' ':
+							k.Runes = nil
+							k.Type = KeyCtrlAt
+						case '[', '\\', ']', '^', '_':
+							k.Runes = nil
+							k.Type = KeyCtrlOpenBracket - KeyType(r) + '['
+						case 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
+							'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
+							'u', 'v', 'w', 'x', 'y', 'z':
+							k.Runes = nil
+							k.Type = KeyCtrlA - KeyType(r) + 'a'
+						case '?':
+							k.Runes = nil
+							k.Type = KeyCtrlQuestionMark
+						}
+					case KeyPgUp, KeyPgDown, KeyHome, KeyEnd:
+						k.Runes = nil
+						k.Type = KeyCtrlPgUp - e.Type + KeyPgUp
+					case KeyUp, KeyDown, KeyRight, KeyLeft:
+						k.Runes = nil
+						k.Type = KeyCtrlUp - e.Type + KeyUp
+					}
+				case e.Mod.Contains(ModShift):
+					switch e.Type {
+					case KeyTab:
+						k.Runes = nil
+						k.Type = KeyShiftTab
+					case KeyUp, KeyDown, KeyRight, KeyLeft:
+						k.Runes = nil
+						k.Type = KeyShiftUp - e.Type + KeyUp
+						k.Runes = nil
+					case KeyHome, KeyEnd:
+						k.Runes = nil
+						k.Type = KeyShiftHome - e.Type + KeyHome
+					}
+				}
+
+				switch k.Type {
+				case KeyRunes: // KeyRunes
+					if len(k.Runes) > 0 {
+						incomingMsgs = append(incomingMsgs, k)
+					}
+				default:
+					incomingMsgs = append(incomingMsgs, k)
+				}
+			case MouseClickMsg:
+				m := toMouseMsg(Mouse(e))
+				m.Action = MouseActionPress
+				m.Type = e.Button
+				incomingMsgs = append(incomingMsgs, m)
+			case MouseReleaseMsg:
+				m := toMouseMsg(Mouse(e))
+				m.Action = MouseActionRelease
+				m.Type = MouseRelease
+				incomingMsgs = append(incomingMsgs, m)
+			case MouseWheelMsg:
+				m := toMouseMsg(Mouse(e))
+				m.Action = MouseActionPress
+				m.Type = e.Button
+				incomingMsgs = append(incomingMsgs, m)
+			case MouseMotionMsg:
+				m := toMouseMsg(Mouse(e))
+				m.Action = MouseActionMotion
+				m.Type = MouseMotion
+				incomingMsgs = append(incomingMsgs, m)
+			}
+
+			for _, m := range incomingMsgs {
+				select {
+				case msgs <- m:
+				case <-ctx.Done():
+					err := ctx.Err()
+					if err != nil {
+						err = fmt.Errorf("found context error while reading input: %w", err)
+					}
+					return err
+				}
+			}
+		}
+	}
+}
+
 func (p *Program) readLoop() {
 	defer close(p.readLoopDone)
 
-	err := readInputs(p.ctx, p.msgs, p.cancelReader)
+	err := readInputs(p.ctx, p.msgs, p.inputReader)
 	if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
 		select {
 		case <-p.ctx.Done():
diff --git a/tutorials/go.mod b/tutorials/go.mod
index 2b31c4f..fa32682 100644
--- a/tutorials/go.mod
+++ b/tutorials/go.mod
@@ -5,20 +5,18 @@ go 1.18
 require github.com/charmbracelet/bubbletea v0.25.0
 
 require (
-	github.com/charmbracelet/x/ansi v0.1.1 // indirect
+	github.com/charmbracelet/x/ansi v0.1.4 // indirect
 	github.com/charmbracelet/x/input v0.1.0 // indirect
 	github.com/charmbracelet/x/term v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.1.0 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
-	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
-	golang.org/x/sync v0.7.0 // indirect
-	golang.org/x/sys v0.20.0 // indirect
-	golang.org/x/text v0.13.0 // indirect
+	golang.org/x/sync v0.8.0 // indirect
+	golang.org/x/sys v0.24.0 // indirect
 )
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/tutorials/go.sum b/tutorials/go.sum
index 1c500b5..afe8f92 100644
--- a/tutorials/go.sum
+++ b/tutorials/go.sum
@@ -1,5 +1,5 @@
-github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk=
-github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
+github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
+github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
 github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
 github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
 github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
@@ -8,8 +8,6 @@ github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wp
 github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
-github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
-github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -22,10 +20,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
-golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
+golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
diff --git a/win32input.go b/win32input.go
new file mode 100644
index 0000000..526f61a
--- /dev/null
+++ b/win32input.go
@@ -0,0 +1,240 @@
+package tea
+
+import (
+	"unicode"
+
+	"github.com/erikgeiser/coninput"
+)
+
+func parseWin32InputKeyEvent(vkc coninput.VirtualKeyCode, _ coninput.VirtualKeyCode, r rune, keyDown bool, cks coninput.ControlKeyState, repeatCount uint16) Msg {
+	var key Key
+	isCtrl := cks.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED)
+	switch vkc {
+	case coninput.VK_SHIFT:
+		// We currently ignore these keys when they are pressed alone.
+		return nil
+	case coninput.VK_MENU:
+		if cks.Contains(coninput.LEFT_ALT_PRESSED) {
+			key = Key{Type: KeyLeftAlt}
+		} else if cks.Contains(coninput.RIGHT_ALT_PRESSED) {
+			key = Key{Type: KeyRightAlt}
+		} else if !keyDown {
+			return nil
+		}
+	case coninput.VK_CONTROL:
+		if cks.Contains(coninput.LEFT_CTRL_PRESSED) {
+			key = Key{Type: KeyLeftCtrl}
+		} else if cks.Contains(coninput.RIGHT_CTRL_PRESSED) {
+			key = Key{Type: KeyRightCtrl}
+		} else if !keyDown {
+			return nil
+		}
+	case coninput.VK_CAPITAL:
+		key = Key{Type: KeyCapsLock}
+	default:
+		var ok bool
+		key, ok = vkKeyEvent[vkc]
+		if !ok {
+			if isCtrl {
+				key = vkCtrlRune(key, r, vkc)
+			} else {
+				key = Key{Runes: []rune{r}}
+			}
+		}
+	}
+
+	if isCtrl {
+		key.Mod |= ModCtrl
+	}
+	if cks.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED) {
+		key.Mod |= ModAlt
+	}
+	if cks.Contains(coninput.SHIFT_PRESSED) {
+		key.Mod |= ModShift
+	}
+	if cks.Contains(coninput.CAPSLOCK_ON) {
+		key.Mod |= ModCapsLock
+	}
+	if cks.Contains(coninput.NUMLOCK_ON) {
+		key.Mod |= ModNumLock
+	}
+	if cks.Contains(coninput.SCROLLLOCK_ON) {
+		key.Mod |= ModScrollLock
+	}
+
+	// Use the unshifted key
+	if cks.Contains(coninput.SHIFT_PRESSED ^ coninput.CAPSLOCK_ON) {
+		key.altRune = unicode.ToUpper(key.Rune())
+	} else {
+		key.altRune = unicode.ToLower(key.Rune())
+	}
+
+	var e Msg = KeyPressMsg(key)
+	key.IsRepeat = repeatCount > 1
+	if !keyDown {
+		e = KeyReleaseMsg(key)
+	}
+
+	if repeatCount <= 1 {
+		return e
+	}
+
+	var kevents []Msg
+	for i := 0; i < int(repeatCount); i++ {
+		kevents = append(kevents, e)
+	}
+
+	return multiMsg(kevents)
+}
+
+var vkKeyEvent = map[coninput.VirtualKeyCode]Key{
+	coninput.VK_RETURN:    {Type: KeyEnter},
+	coninput.VK_BACK:      {Type: KeyBackspace},
+	coninput.VK_TAB:       {Type: KeyTab},
+	coninput.VK_ESCAPE:    {Type: KeyEscape},
+	coninput.VK_SPACE:     {Type: KeySpace, Runes: []rune{' '}},
+	coninput.VK_UP:        {Type: KeyUp},
+	coninput.VK_DOWN:      {Type: KeyDown},
+	coninput.VK_RIGHT:     {Type: KeyRight},
+	coninput.VK_LEFT:      {Type: KeyLeft},
+	coninput.VK_HOME:      {Type: KeyHome},
+	coninput.VK_END:       {Type: KeyEnd},
+	coninput.VK_PRIOR:     {Type: KeyPgUp},
+	coninput.VK_NEXT:      {Type: KeyPgDown},
+	coninput.VK_DELETE:    {Type: KeyDelete},
+	coninput.VK_SELECT:    {Type: KeySelect},
+	coninput.VK_SNAPSHOT:  {Type: KeyPrintScreen},
+	coninput.VK_INSERT:    {Type: KeyInsert},
+	coninput.VK_LWIN:      {Type: KeyLeftSuper},
+	coninput.VK_RWIN:      {Type: KeyRightSuper},
+	coninput.VK_APPS:      {Type: KeyMenu},
+	coninput.VK_NUMPAD0:   {Type: KeyKp0},
+	coninput.VK_NUMPAD1:   {Type: KeyKp1},
+	coninput.VK_NUMPAD2:   {Type: KeyKp2},
+	coninput.VK_NUMPAD3:   {Type: KeyKp3},
+	coninput.VK_NUMPAD4:   {Type: KeyKp4},
+	coninput.VK_NUMPAD5:   {Type: KeyKp5},
+	coninput.VK_NUMPAD6:   {Type: KeyKp6},
+	coninput.VK_NUMPAD7:   {Type: KeyKp7},
+	coninput.VK_NUMPAD8:   {Type: KeyKp8},
+	coninput.VK_NUMPAD9:   {Type: KeyKp9},
+	coninput.VK_MULTIPLY:  {Type: KeyKpMultiply},
+	coninput.VK_ADD:       {Type: KeyKpPlus},
+	coninput.VK_SEPARATOR: {Type: KeyKpComma},
+	coninput.VK_SUBTRACT:  {Type: KeyKpMinus},
+	coninput.VK_DECIMAL:   {Type: KeyKpDecimal},
+	coninput.VK_DIVIDE:    {Type: KeyKpDivide},
+	coninput.VK_F1:        {Type: KeyF1},
+	coninput.VK_F2:        {Type: KeyF2},
+	coninput.VK_F3:        {Type: KeyF3},
+	coninput.VK_F4:        {Type: KeyF4},
+	coninput.VK_F5:        {Type: KeyF5},
+	coninput.VK_F6:        {Type: KeyF6},
+	coninput.VK_F7:        {Type: KeyF7},
+	coninput.VK_F8:        {Type: KeyF8},
+	coninput.VK_F9:        {Type: KeyF9},
+	coninput.VK_F10:       {Type: KeyF10},
+	coninput.VK_F11:       {Type: KeyF11},
+	coninput.VK_F12:       {Type: KeyF12},
+	coninput.VK_F13:       {Type: KeyF13},
+	coninput.VK_F14:       {Type: KeyF14},
+	coninput.VK_F15:       {Type: KeyF15},
+	coninput.VK_F16:       {Type: KeyF16},
+	coninput.VK_F17:       {Type: KeyF17},
+	coninput.VK_F18:       {Type: KeyF18},
+	coninput.VK_F19:       {Type: KeyF19},
+	coninput.VK_F20:       {Type: KeyF20},
+	coninput.VK_F21:       {Type: KeyF21},
+	coninput.VK_F22:       {Type: KeyF22},
+	coninput.VK_F23:       {Type: KeyF23},
+	coninput.VK_F24:       {Type: KeyF24},
+	coninput.VK_NUMLOCK:   {Type: KeyNumLock},
+	coninput.VK_SCROLL:    {Type: KeyScrollLock},
+	coninput.VK_LSHIFT:    {Type: KeyLeftShift},
+	coninput.VK_RSHIFT:    {Type: KeyRightShift},
+	coninput.VK_LCONTROL:  {Type: KeyLeftCtrl},
+	coninput.VK_RCONTROL:  {Type: KeyRightCtrl},
+	coninput.VK_LMENU:     {Type: KeyLeftAlt},
+	coninput.VK_RMENU:     {Type: KeyRightAlt},
+	coninput.VK_OEM_4:     {Runes: []rune{'['}},
+	// TODO: add more keys
+}
+
+func vkCtrlRune(k Key, r rune, kc coninput.VirtualKeyCode) Key {
+	switch r {
+	case '@':
+		k.Runes = []rune{'@'}
+	case '\x01':
+		k.Runes = []rune{'a'}
+	case '\x02':
+		k.Runes = []rune{'b'}
+	case '\x03':
+		k.Runes = []rune{'c'}
+	case '\x04':
+		k.Runes = []rune{'d'}
+	case '\x05':
+		k.Runes = []rune{'e'}
+	case '\x06':
+		k.Runes = []rune{'f'}
+	case '\a':
+		k.Runes = []rune{'g'}
+	case '\b':
+		k.Runes = []rune{'h'}
+	case '\t':
+		k.Runes = []rune{'i'}
+	case '\n':
+		k.Runes = []rune{'j'}
+	case '\v':
+		k.Runes = []rune{'k'}
+	case '\f':
+		k.Runes = []rune{'l'}
+	case '\r':
+		k.Runes = []rune{'m'}
+	case '\x0e':
+		k.Runes = []rune{'n'}
+	case '\x0f':
+		k.Runes = []rune{'o'}
+	case '\x10':
+		k.Runes = []rune{'p'}
+	case '\x11':
+		k.Runes = []rune{'q'}
+	case '\x12':
+		k.Runes = []rune{'r'}
+	case '\x13':
+		k.Runes = []rune{'s'}
+	case '\x14':
+		k.Runes = []rune{'t'}
+	case '\x15':
+		k.Runes = []rune{'u'}
+	case '\x16':
+		k.Runes = []rune{'v'}
+	case '\x17':
+		k.Runes = []rune{'w'}
+	case '\x18':
+		k.Runes = []rune{'x'}
+	case '\x19':
+		k.Runes = []rune{'y'}
+	case '\x1a':
+		k.Runes = []rune{'z'}
+	case '\x1b':
+		k.Runes = []rune{']'}
+	case '\x1c':
+		k.Runes = []rune{'\\'}
+	case '\x1f':
+		k.Runes = []rune{'_'}
+	}
+
+	switch kc {
+	case coninput.VK_OEM_4:
+		k.Runes = []rune{'['}
+	}
+
+	// https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
+	if len(k.Runes) == 0 &&
+		(kc >= 0x30 && kc <= 0x39) ||
+		(kc >= 0x41 && kc <= 0x5a) {
+		k.Runes = []rune{rune(kc)}
+	}
+
+	return k
+}
diff --git a/xterm.go b/xterm.go
new file mode 100644
index 0000000..f09caeb
--- /dev/null
+++ b/xterm.go
@@ -0,0 +1,42 @@
+package tea
+
+import (
+	"github.com/charmbracelet/x/ansi"
+)
+
+func parseXTermModifyOtherKeys(csi *ansi.CsiSequence) Msg {
+	// XTerm modify other keys starts with ESC [ 27 ; <modifier> ; <code> ~
+	mod := KeyMod(csi.Param(1) - 1)
+	r := rune(csi.Param(2))
+
+	switch r {
+	case ansi.BS:
+		return KeyPressMsg{Mod: mod, Type: KeyBackspace}
+	case ansi.HT:
+		return KeyPressMsg{Mod: mod, Type: KeyTab}
+	case ansi.CR:
+		return KeyPressMsg{Mod: mod, Type: KeyEnter}
+	case ansi.ESC:
+		return KeyPressMsg{Mod: mod, Type: KeyEscape}
+	case ansi.DEL:
+		return KeyPressMsg{Mod: mod, Type: KeyBackspace}
+	}
+
+	// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
+	return KeyPressMsg{
+		Mod:   mod,
+		Runes: []rune{r},
+	}
+}
+
+// ModifyOtherKeysMsg is a message that represents XTerm modifyOtherKeys
+// report. Querying the terminal for the modifyOtherKeys mode will return a
+// ModifyOtherKeysMsg message with the current mode set.
+//
+//	0: disable
+//	1: enable mode 1
+//	2: enable mode 2
+//
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_
+// See: https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyOtherKeys
+type ModifyOtherKeysMsg uint8


Test patch:
diff --git a/driver_test.go b/driver_test.go
new file mode 100644
index 0000000..214d536
--- /dev/null
+++ b/driver_test.go
@@ -0,0 +1,25 @@
+package tea
+
+import (
+	"io"
+	"strings"
+	"testing"
+)
+
+func BenchmarkDriver(b *testing.B) {
+	input := "\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~"
+	rdr := strings.NewReader(input)
+	drv, err := newDriver(rdr, "dumb", 0)
+	if err != nil {
+		b.Fatalf("could not create driver: %v", err)
+	}
+
+	b.ReportAllocs()
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		rdr.Reset(input)
+		if _, err := drv.ReadEvents(); err != nil && err != io.EOF {
+			b.Errorf("error reading input: %v", err)
+		}
+	}
+}
diff --git a/focus_test.go b/focus_test.go
new file mode 100644
index 0000000..2871c3a
--- /dev/null
+++ b/focus_test.go
@@ -0,0 +1,25 @@
+package tea
+
+import (
+	"testing"
+)
+
+func TestFocus(t *testing.T) {
+	_, e := parseSequence([]byte("\x1b[I"))
+	switch e.(type) {
+	case FocusMsg:
+		// ok
+	default:
+		t.Error("invalid sequence")
+	}
+}
+
+func TestBlur(t *testing.T) {
+	_, e := parseSequence([]byte("\x1b[O"))
+	switch e.(type) {
+	case BlurMsg:
+		// ok
+	default:
+		t.Error("invalid sequence")
+	}
+}
diff --git a/key_test.go b/key_test.go
index 67b0c50..d46da05 100644
--- a/key_test.go
+++ b/key_test.go
@@ -9,85 +9,78 @@ import (
 	"io"
 	"math/rand"
 	"reflect"
+	"regexp"
 	"runtime"
 	"sort"
 	"strings"
 	"sync"
 	"testing"
 	"time"
+
+	"github.com/charmbracelet/x/ansi"
 )
 
+var sequences = buildKeysTable(_FlagTerminfo, "dumb")
+
 func TestKeyString(t *testing.T) {
 	t.Run("alt+space", func(t *testing.T) {
-		if got := KeyMsg(Key{
-			Type: KeySpace,
-			Alt:  true,
-		}).String(); got != "alt+ " {
-			t.Fatalf(`expected a "alt+ ", got %q`, got)
+		k := KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModAlt}
+		if got := k.String(); got != "alt+space" {
+			t.Fatalf(`expected a "alt+space ", got %q`, got)
 		}
 	})
 
 	t.Run("runes", func(t *testing.T) {
-		if got := KeyMsg(Key{
-			Type:  KeyRunes,
-			Runes: []rune{'a'},
-		}).String(); got != "a" {
+		k := KeyPressMsg{Runes: []rune{'a'}}
+		if got := k.String(); got != "a" {
 			t.Fatalf(`expected an "a", got %q`, got)
 		}
 	})
 
 	t.Run("invalid", func(t *testing.T) {
-		if got := KeyMsg(Key{
-			Type: KeyType(99999),
-		}).String(); got != "" {
-			t.Fatalf(`expected a "", got %q`, got)
+		k := KeyPressMsg{Type: 99999}
+		if got := k.String(); got != "" {
+			t.Fatalf(`expected a "unknown", got %q`, got)
 		}
 	})
 }
 
 func TestKeyTypeString(t *testing.T) {
 	t.Run("space", func(t *testing.T) {
-		if got := KeySpace.String(); got != " " {
-			t.Fatalf(`expected a " ", got %q`, got)
+		if got := KeySpace.String(); got != "space" {
+			t.Fatalf(`expected a "space", got %q`, got)
 		}
 	})
 
 	t.Run("invalid", func(t *testing.T) {
 		if got := KeyType(99999).String(); got != "" {
-			t.Fatalf(`expected a "", got %q`, got)
+			t.Fatalf(`expected a "unknown", got %q`, got)
 		}
 	})
 }
 
 type seqTest struct {
-	seq []byte
-	msg Msg
+	seq  []byte
+	msgs []Msg
 }
 
+var f3CurPosRegexp = regexp.MustCompile(`\x1b\[1;(\d+)R`)
+
 // buildBaseSeqTests returns sequence tests that are valid for the
 // detectSequence() function.
 func buildBaseSeqTests() []seqTest {
 	td := []seqTest{}
 	for seq, key := range sequences {
-		key := key
-		td = append(td, seqTest{[]byte(seq), KeyMsg(key)})
-		if !key.Alt {
-			key.Alt = true
-			td = append(td, seqTest{[]byte("\x1b" + seq), KeyMsg(key)})
-		}
-	}
-	// Add all the control characters.
-	for i := keyNUL + 1; i <= keyDEL; i++ {
-		if i == keyESC {
-			// Not handled in detectSequence(), so not part of the base test
-			// suite.
-			continue
-		}
-		td = append(td, seqTest{[]byte{byte(i)}, KeyMsg{Type: i}})
-		td = append(td, seqTest{[]byte{'\x1b', byte(i)}, KeyMsg{Type: i, Alt: true}})
-		if i == keyUS {
-			i = keyDEL - 1
+		k := KeyPressMsg(key)
+		st := seqTest{seq: []byte(seq), msgs: []Msg{k}}
+
+		// XXX: This is a special case to handle F3 key sequence and cursor
+		// position report having the same sequence. See [parseCsi] for more
+		// information.
+		if f3CurPosRegexp.MatchString(seq) {
+			st.msgs = []Msg{k, CursorPositionMsg{Row: 1, Column: int(key.Mod) + 1}}
 		}
+		td = append(td, st)
 	}
 
 	// Additional special cases.
@@ -95,102 +88,129 @@ func buildBaseSeqTests() []seqTest {
 		// Unrecognized CSI sequence.
 		seqTest{
 			[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
-			unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
+			[]Msg{
+				UnknownMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
+			},
 		},
 		// A lone space character.
 		seqTest{
 			[]byte{' '},
-			KeyMsg{Type: KeySpace, Runes: []rune(" ")},
+			[]Msg{
+				KeyPressMsg{Type: KeySpace, Runes: []rune{' '}},
+			},
 		},
 		// An escape character with the alt modifier.
 		seqTest{
 			[]byte{'\x1b', ' '},
-			KeyMsg{Type: KeySpace, Runes: []rune(" "), Alt: true},
+			[]Msg{
+				KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModAlt},
+			},
 		},
 	)
 	return td
 }
 
-func TestDetectSequence(t *testing.T) {
+func TestParseSequence(t *testing.T) {
 	td := buildBaseSeqTests()
-	for _, tc := range td {
-		t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
-			hasSeq, width, msg := detectSequence(tc.seq)
-			if !hasSeq {
-				t.Fatalf("no sequence found")
-			}
-			if width != len(tc.seq) {
-				t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq))
-			}
-			if !reflect.DeepEqual(tc.msg, msg) {
-				t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg)
-			}
-		})
-	}
-}
-
-func TestDetectOneMsg(t *testing.T) {
-	td := buildBaseSeqTests()
-	// Add tests for the inputs that detectOneMsg() can parse, but
-	// detectSequence() cannot.
 	td = append(td,
+		// focus/blur
+		seqTest{
+			[]byte{'\x1b', '[', 'I'},
+			[]Msg{
+				FocusMsg{},
+			},
+		},
+		seqTest{
+			[]byte{'\x1b', '[', 'O'},
+			[]Msg{
+				BlurMsg{},
+			},
+		},
 		// Mouse event.
 		seqTest{
 			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
-			MouseMsg{X: 32, Y: 16, Type: MouseWheelUp, Button: MouseButtonWheelUp, Action: MouseActionPress},
+			[]Msg{
+				MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelUp},
+			},
 		},
 		// SGR Mouse event.
 		seqTest{
 			[]byte("\x1b[<0;33;17M"),
-			MouseMsg{X: 32, Y: 16, Type: MouseLeft, Button: MouseButtonLeft, Action: MouseActionPress},
+			[]Msg{
+				MouseClickMsg{X: 32, Y: 16, Button: MouseLeft},
+			},
 		},
 		// Runes.
 		seqTest{
 			[]byte{'a'},
-			KeyMsg{Type: KeyRunes, Runes: []rune("a")},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{'a'}},
+			},
 		},
 		seqTest{
 			[]byte{'\x1b', 'a'},
-			KeyMsg{Type: KeyRunes, Runes: []rune("a"), Alt: true},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{'a'}, Mod: ModAlt},
+			},
 		},
 		seqTest{
 			[]byte{'a', 'a', 'a'},
-			KeyMsg{Type: KeyRunes, Runes: []rune("aaa")},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{'a'}},
+				KeyPressMsg{Runes: []rune{'a'}},
+				KeyPressMsg{Runes: []rune{'a'}},
+			},
 		},
 		// Multi-byte rune.
 		seqTest{
 			[]byte("☃"),
-			KeyMsg{Type: KeyRunes, Runes: []rune("☃")},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{'☃'}},
+			},
 		},
 		seqTest{
 			[]byte("\x1b☃"),
-			KeyMsg{Type: KeyRunes, Runes: []rune("☃"), Alt: true},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{'☃'}, Mod: ModAlt},
+			},
 		},
 		// Standalone control chacters.
 		seqTest{
 			[]byte{'\x1b'},
-			KeyMsg{Type: KeyEscape},
+			[]Msg{
+				KeyPressMsg{Type: KeyEscape},
+			},
 		},
 		seqTest{
-			[]byte{byte(keySOH)},
-			KeyMsg{Type: KeyCtrlA},
+			[]byte{ansi.SOH},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl},
+			},
 		},
 		seqTest{
-			[]byte{'\x1b', byte(keySOH)},
-			KeyMsg{Type: KeyCtrlA, Alt: true},
+			[]byte{'\x1b', ansi.SOH},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl | ModAlt},
+			},
 		},
 		seqTest{
-			[]byte{byte(keyNUL)},
-			KeyMsg{Type: KeyCtrlAt},
+			[]byte{ansi.NUL},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{' '}, Type: KeySpace, Mod: ModCtrl},
+			},
 		},
 		seqTest{
-			[]byte{'\x1b', byte(keyNUL)},
-			KeyMsg{Type: KeyCtrlAt, Alt: true},
+			[]byte{'\x1b', ansi.NUL},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{' '}, Type: KeySpace, Mod: ModCtrl | ModAlt},
+			},
 		},
-		// Invalid characters.
+		// C1 control characters.
 		seqTest{
 			[]byte{'\x80'},
-			unknownInputByteMsg(0x80),
+			[]Msg{
+				KeyPressMsg{Runes: []rune{0x80 - '@'}, Mod: ModCtrl | ModAlt},
+			},
 		},
 	)
 
@@ -199,39 +219,58 @@ func TestDetectOneMsg(t *testing.T) {
 		// This is incorrect, but it makes our test fail if we try it out.
 		td = append(td, seqTest{
 			[]byte{'\xfe'},
-			unknownInputByteMsg(0xfe),
+			[]Msg{
+				UnknownMsg(rune(0xfe)),
+			},
 		})
 	}
 
 	for _, tc := range td {
 		t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
-			width, msg := detectOneMsg(tc.seq, false /* canHaveMoreData */)
-			if width != len(tc.seq) {
-				t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq))
+			var events []Msg
+			buf := tc.seq
+			for len(buf) > 0 {
+				width, msg := parseSequence(buf)
+				switch msg := msg.(type) {
+				case multiMsg:
+					events = append(events, msg...)
+				default:
+					events = append(events, msg)
+				}
+				buf = buf[width:]
 			}
-			if !reflect.DeepEqual(tc.msg, msg) {
-				t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg)
+			if !reflect.DeepEqual(tc.msgs, events) {
+				t.Errorf("\nexpected event:\n    %#v\ngot:\n    %#v", tc.msgs, events)
 			}
 		})
 	}
 }
 
 func TestReadLongInput(t *testing.T) {
-	input := strings.Repeat("a", 1000)
-	msgs := testReadInputs(t, bytes.NewReader([]byte(input)))
-	if len(msgs) != 1 {
-		t.Errorf("expected 1 messages, got %d", len(msgs))
+	expect := make([]Msg, 1000)
+	for i := 0; i < 1000; i++ {
+		expect[i] = KeyPressMsg{Runes: []rune{'a'}}
 	}
-	km := msgs[0]
-	k := Key(km.(KeyMsg))
-	if k.Type != KeyRunes {
-		t.Errorf("expected key runes, got %d", k.Type)
+	input := strings.Repeat("a", 1000)
+	drv, err := newDriver(strings.NewReader(input), "dumb", 0)
+	if err != nil {
+		t.Fatalf("unexpected input driver error: %v", err)
 	}
-	if len(k.Runes) != 1000 || !reflect.DeepEqual(k.Runes, []rune(input)) {
-		t.Errorf("unexpected runes: %+v", k)
+
+	var msgs []Msg
+	for {
+		events, err := drv.ReadEvents()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			t.Fatalf("unexpected input error: %v", err)
+		}
+		msgs = append(msgs, events...)
 	}
-	if k.Alt {
-		t.Errorf("unexpected alt")
+
+	if !reflect.DeepEqual(expect, msgs) {
+		t.Errorf("unexpected messages, expected:\n    %+v\ngot:\n    %+v", expect, msgs)
 	}
 }
 
@@ -242,199 +281,185 @@ func TestReadInput(t *testing.T) {
 		out     []Msg
 	}
 	testData := []test{
-		{"a",
+		{
+			"a",
 			[]byte{'a'},
 			[]Msg{
-				KeyMsg{
-					Type:  KeyRunes,
-					Runes: []rune{'a'},
-				},
+				KeyPressMsg{Runes: []rune{'a'}},
 			},
 		},
-		{" ",
+		{
+			"space",
 			[]byte{' '},
 			[]Msg{
-				KeyMsg{
-					Type:  KeySpace,
-					Runes: []rune{' '},
-				},
+				KeyPressMsg{Type: KeySpace, Runes: []rune{' '}},
 			},
 		},
-		{"a alt+a",
+		{
+			"a alt+a",
 			[]byte{'a', '\x1b', 'a'},
 			[]Msg{
-				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
-				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true},
+				KeyPressMsg{Runes: []rune{'a'}},
+				KeyPressMsg{Runes: []rune{'a'}, Mod: ModAlt},
 			},
 		},
-		{"a alt+a a",
+		{
+			"a alt+a a",
 			[]byte{'a', '\x1b', 'a', 'a'},
 			[]Msg{
-				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
-				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true},
-				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
+				KeyPressMsg{Runes: []rune{'a'}},
+				KeyPressMsg{Runes: []rune{'a'}, Mod: ModAlt},
+				KeyPressMsg{Runes: []rune{'a'}},
 			},
 		},
-		{"ctrl+a",
-			[]byte{byte(keySOH)},
+		{
+			"ctrl+a",
+			[]byte{byte(ansi.SOH)},
 			[]Msg{
-				KeyMsg{
-					Type: KeyCtrlA,
-				},
+				KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl},
 			},
 		},
-		{"ctrl+a ctrl+b",
-			[]byte{byte(keySOH), byte(keySTX)},
+		{
+			"ctrl+a ctrl+b",
+			[]byte{byte(ansi.SOH), byte(ansi.STX)},
 			[]Msg{
-				KeyMsg{Type: KeyCtrlA},
-				KeyMsg{Type: KeyCtrlB},
+				KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl},
+				KeyPressMsg{Runes: []rune{'b'}, Mod: ModCtrl},
 			},
 		},
-		{"alt+a",
+		{
+			"alt+a",
 			[]byte{byte(0x1b), 'a'},
 			[]Msg{
-				KeyMsg{
-					Type:  KeyRunes,
-					Alt:   true,
-					Runes: []rune{'a'},
-				},
+				KeyPressMsg{Mod: ModAlt, Runes: []rune{'a'}},
 			},
 		},
-		{"abcd",
+		{
+			"a b c d",
 			[]byte{'a', 'b', 'c', 'd'},
 			[]Msg{
-				KeyMsg{
-					Type:  KeyRunes,
-					Runes: []rune{'a', 'b', 'c', 'd'},
-				},
+				KeyPressMsg{Runes: []rune{'a'}},
+				KeyPressMsg{Runes: []rune{'b'}},
+				KeyPressMsg{Runes: []rune{'c'}},
+				KeyPressMsg{Runes: []rune{'d'}},
 			},
 		},
-		{"up",
+		{
+			"up",
 			[]byte("\x1b[A"),
 			[]Msg{
-				KeyMsg{
-					Type: KeyUp,
-				},
+				KeyPressMsg{Type: KeyUp},
 			},
 		},
-		{"wheel up",
+		{
+			"wheel up",
 			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
 			[]Msg{
-				MouseMsg{
-					X:      32,
-					Y:      16,
-					Type:   MouseWheelUp,
-					Button: MouseButtonWheelUp,
-					Action: MouseActionPress,
-				},
+				MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelUp},
 			},
 		},
-		{"left motion release",
+		{
+			"left motion release",
 			[]byte{
 				'\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33),
 				'\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33),
 			},
 			[]Msg{
-				MouseMsg(MouseEvent{
-					X:      32,
-					Y:      16,
-					Type:   MouseLeft,
-					Button: MouseButtonLeft,
-					Action: MouseActionMotion,
-				}),
-				MouseMsg(MouseEvent{
-					X:      64,
-					Y:      32,
-					Type:   MouseRelease,
-					Button: MouseButtonNone,
-					Action: MouseActionRelease,
-				}),
+				MouseMotionMsg{X: 32, Y: 16, Button: MouseLeft},
+				MouseReleaseMsg{X: 64, Y: 32, Button: MouseNone},
 			},
 		},
-		{"shift+tab",
+		{
+			"shift+tab",
 			[]byte{'\x1b', '[', 'Z'},
 			[]Msg{
-				KeyMsg{
-					Type: KeyShiftTab,
-				},
+				KeyPressMsg{Type: KeyTab, Mod: ModShift},
 			},
 		},
-		{"enter",
+		{
+			"enter",
 			[]byte{'\r'},
-			[]Msg{KeyMsg{Type: KeyEnter}},
+			[]Msg{KeyPressMsg{Type: KeyEnter}},
 		},
-		{"alt+enter",
+		{
+			"alt+enter",
 			[]byte{'\x1b', '\r'},
 			[]Msg{
-				KeyMsg{
-					Type: KeyEnter,
-					Alt:  true,
-				},
+				KeyPressMsg{Type: KeyEnter, Mod: ModAlt},
 			},
 		},
-		{"insert",
+		{
+			"insert",
 			[]byte{'\x1b', '[', '2', '~'},
 			[]Msg{
-				KeyMsg{
-					Type: KeyInsert,
-				},
+				KeyPressMsg{Type: KeyInsert},
 			},
 		},
-		{"alt+ctrl+a",
-			[]byte{'\x1b', byte(keySOH)},
+		{
+			"ctrl+alt+a",
+			[]byte{'\x1b', byte(ansi.SOH)},
 			[]Msg{
-				KeyMsg{
-					Type: KeyCtrlA,
-					Alt:  true,
-				},
+				KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl | ModAlt},
 			},
 		},
-		{"?CSI[45 45 45 45 88]?",
+		{
+			"CSI?----X?",
 			[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
-			[]Msg{unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
+			[]Msg{UnknownMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
 		},
 		// Powershell sequences.
-		{"up",
+		{
+			"up",
 			[]byte{'\x1b', 'O', 'A'},
-			[]Msg{KeyMsg{Type: KeyUp}},
+			[]Msg{KeyPressMsg{Type: KeyUp}},
 		},
-		{"down",
+		{
+			"down",
 			[]byte{'\x1b', 'O', 'B'},
-			[]Msg{KeyMsg{Type: KeyDown}},
+			[]Msg{KeyPressMsg{Type: KeyDown}},
 		},
-		{"right",
+		{
+			"right",
 			[]byte{'\x1b', 'O', 'C'},
-			[]Msg{KeyMsg{Type: KeyRight}},
+			[]Msg{KeyPressMsg{Type: KeyRight}},
 		},
-		{"left",
+		{
+			"left",
 			[]byte{'\x1b', 'O', 'D'},
-			[]Msg{KeyMsg{Type: KeyLeft}},
+			[]Msg{KeyPressMsg{Type: KeyLeft}},
 		},
-		{"alt+enter",
+		{
+			"alt+enter",
 			[]byte{'\x1b', '\x0d'},
-			[]Msg{KeyMsg{Type: KeyEnter, Alt: true}},
+			[]Msg{KeyPressMsg{Type: KeyEnter, Mod: ModAlt}},
 		},
-		{"alt+backspace",
+		{
+			"alt+backspace",
 			[]byte{'\x1b', '\x7f'},
-			[]Msg{KeyMsg{Type: KeyBackspace, Alt: true}},
+			[]Msg{KeyPressMsg{Type: KeyBackspace, Mod: ModAlt}},
 		},
-		{"ctrl+@",
+		{
+			"ctrl+space",
 			[]byte{'\x00'},
-			[]Msg{KeyMsg{Type: KeyCtrlAt}},
+			[]Msg{KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModCtrl}},
 		},
-		{"alt+ctrl+@",
+		{
+			"ctrl+alt+space",
 			[]byte{'\x1b', '\x00'},
-			[]Msg{KeyMsg{Type: KeyCtrlAt, Alt: true}},
+			[]Msg{KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModCtrl | ModAlt}},
 		},
-		{"esc",
+		{
+			"esc",
 			[]byte{'\x1b'},
-			[]Msg{KeyMsg{Type: KeyEsc}},
+			[]Msg{KeyPressMsg{Type: KeyEscape}},
 		},
-		{"alt+esc",
+		{
+			"alt+esc",
 			[]byte{'\x1b', '\x1b'},
-			[]Msg{KeyMsg{Type: KeyEsc, Alt: true}},
+			[]Msg{KeyPressMsg{Type: KeyEscape, Mod: ModAlt}},
 		},
-		{"[a b] o",
+		{
+			"a b o",
 			[]byte{
 				'\x1b', '[', '2', '0', '0', '~',
 				'a', ' ', 'b',
@@ -442,38 +467,42 @@ func TestReadInput(t *testing.T) {
 				'o',
 			},
 			[]Msg{
-				KeyMsg{Type: KeyRunes, Runes: []rune("a b"), Paste: true},
-				KeyMsg{Type: KeyRunes, Runes: []rune("o")},
+				PasteStartMsg{},
+				PasteMsg("a b"),
+				PasteEndMsg{},
+				KeyPressMsg{Runes: []rune{'o'}},
 			},
 		},
-		{"[a\x03\nb]",
+		{
+			"a\x03\nb",
 			[]byte{
 				'\x1b', '[', '2', '0', '0', '~',
 				'a', '\x03', '\n', 'b',
-				'\x1b', '[', '2', '0', '1', '~'},
+				'\x1b', '[', '2', '0', '1', '~',
+			},
 			[]Msg{
-				KeyMsg{Type: KeyRunes, Runes: []rune("a\x03\nb"), Paste: true},
+				PasteStartMsg{},
+				PasteMsg("a\x03\nb"),
+				PasteEndMsg{},
+			},
+		},
+		{
+			"?0xfe?",
+			[]byte{'\xfe'},
+			[]Msg{
+				UnknownMsg(rune(0xfe)),
+			},
+		},
+		{
+			"a ?0xfe?   b",
+			[]byte{'a', '\xfe', ' ', 'b'},
+			[]Msg{
+				KeyPressMsg{Runes: []rune{'a'}},
+				UnknownMsg(rune(0xfe)),
+				KeyPressMsg{Type: KeySpace, Runes: []rune{' '}},
+				KeyPressMsg{Runes: []rune{'b'}},
 			},
 		},
-	}
-	if runtime.GOOS != "windows" {
-		// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
-		// This is incorrect, but it makes our test fail if we try it out.
-		testData = append(testData,
-			test{"?0xfe?",
-				[]byte{'\xfe'},
-				[]Msg{unknownInputByteMsg(0xfe)},
-			},
-			test{"a ?0xfe?   b",
-				[]byte{'a', '\xfe', ' ', 'b'},
-				[]Msg{
-					KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
-					unknownInputByteMsg(0xfe),
-					KeyMsg{Type: KeySpace, Runes: []rune{' '}},
-					KeyMsg{Type: KeyRunes, Runes: []rune{'b'}},
-				},
-			},
-		)
 	}
 
 	for i, td := range testData {
@@ -491,13 +520,8 @@ func TestReadInput(t *testing.T) {
 				}
 			}
 
-			title := buf.String()
-			if title != td.keyname {
-				t.Errorf("expected message titles:\n  %s\ngot:\n  %s", td.keyname, title)
-			}
-
 			if len(msgs) != len(td.out) {
-				t.Fatalf("unexpected message list length: got %d, expected %d\n%#v", len(msgs), len(td.out), msgs)
+				t.Fatalf("unexpected message list length: got %d, expected %d\n  got: %#v\n  expected: %#v\n", len(msgs), len(td.out), msgs, td.out)
 			}
 
 			if !reflect.DeepEqual(td.out, msgs) {
@@ -521,6 +545,11 @@ func testReadInputs(t *testing.T, input io.Reader) []Msg {
 		}
 	}()
 
+	dr, err := newDriver(input, "dumb", 0)
+	if err != nil {
+		t.Fatalf("unexpected input driver error: %v", err)
+	}
+
 	// The messages we're consuming.
 	msgsC := make(chan Msg)
 
@@ -528,7 +557,16 @@ func testReadInputs(t *testing.T, input io.Reader) []Msg {
 	wg.Add(1)
 	go func() {
 		defer wg.Done()
-		inputErr = readAnsiInputs(ctx, msgsC, input)
+		var events []Msg
+		events, inputErr = dr.ReadEvents()
+	out:
+		for _, ev := range events {
+			select {
+			case msgsC <- ev:
+			case <-ctx.Done():
+				break out
+			}
+		}
 		msgsC <- nil
 	}()
 
@@ -612,14 +650,14 @@ func genRandomDataWithSeed(s int64, length int) randTest {
 				res.data = append(res.data, '\x1b')
 			}
 			res.data = append(res.data, 1)
-			res.names = append(res.names, prefix+"ctrl+a")
+			res.names = append(res.names, "ctrl+"+prefix+"a")
 			res.lengths = append(res.lengths, 1+esclen)
 
 		case 1, 2:
 			// A sequence.
 			seqi := r.Intn(len(allseqs))
 			s := allseqs[seqi]
-			if strings.HasPrefix(s.name, "alt+") {
+			if strings.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") {
 				esclen = 0
 				prefix = ""
 				alt = 0
@@ -628,58 +666,31 @@ func genRandomDataWithSeed(s int64, length int) randTest {
 				res.data = append(res.data, '\x1b')
 			}
 			res.data = append(res.data, s.seq...)
-			res.names = append(res.names, prefix+s.name)
+			if strings.HasPrefix(s.name, "ctrl+") {
+				prefix = "ctrl+" + prefix
+			}
+			name := prefix + strings.TrimPrefix(s.name, "ctrl+")
+			res.names = append(res.names, name)
 			res.lengths = append(res.lengths, len(s.seq)+esclen)
 		}
 	}
 	return res
 }
 
-// TestDetectRandomSequencesLex checks that the lex-generated sequence
-// detector works over concatenations of random sequences.
-func TestDetectRandomSequencesLex(t *testing.T) {
-	runTestDetectSequence(t, detectSequence)
-}
-
-func runTestDetectSequence(
-	t *testing.T, detectSequence func(input []byte) (hasSeq bool, width int, msg Msg),
-) {
-	for i := 0; i < 10; i++ {
-		t.Run("", func(t *testing.T) {
-			td := genRandomData(func(s int64) { t.Logf("using random seed: %d", s) }, 1000)
-
-			t.Logf("%#v", td)
-
-			// tn is the event number in td.
-			// i is the cursor in the input data.
-			// w is the length of the last sequence detected.
-			for tn, i, w := 0, 0, 0; i < len(td.data); tn, i = tn+1, i+w {
-				hasSequence, width, msg := detectSequence(td.data[i:])
-				if !hasSequence {
-					t.Fatalf("at %d (ev %d): failed to find sequence", i, tn)
-				}
-				if width != td.lengths[tn] {
-					t.Errorf("at %d (ev %d): expected width %d, got %d", i, tn, td.lengths[tn], width)
-				}
-				w = width
-
-				s, ok := msg.(fmt.Stringer)
-				if !ok {
-					t.Errorf("at %d (ev %d): expected stringer event, got %T", i, tn, msg)
-				} else {
-					if td.names[tn] != s.String() {
-						t.Errorf("at %d (ev %d): expected event %q, got %q", i, tn, td.names[tn], s.String())
-					}
-				}
-			}
-		})
+func FuzzParseSequence(f *testing.F) {
+	for seq := range sequences {
+		f.Add(seq)
 	}
-}
-
-// TestDetectRandomSequencesMap checks that the map-based sequence
-// detector works over concatenations of random sequences.
-func TestDetectRandomSequencesMap(t *testing.T) {
-	runTestDetectSequence(t, detectSequence)
+	f.Add("\x1b]52;?\x07")                      // OSC 52
+	f.Add("\x1b]11;rgb:0000/0000/0000\x1b\\")   // OSC 11
+	f.Add("\x1bP>|charm terminal(0.1.2)\x1b\\") // DCS (XTVERSION)
+	f.Add("\x1b_Gi=123\x1b\\")                  // APC
+	f.Fuzz(func(t *testing.T, seq string) {
+		n, _ := parseSequence([]byte(seq))
+		if n == 0 && seq != "" {
+			t.Errorf("expected a non-zero width for %q", seq)
+		}
+	})
 }
 
 // BenchmarkDetectSequenceMap benchmarks the map-based sequence
@@ -688,7 +699,7 @@ func BenchmarkDetectSequenceMap(b *testing.B) {
 	td := genRandomDataWithSeed(123, 10000)
 	for i := 0; i < b.N; i++ {
 		for j, w := 0, 0; j < len(td.data); j += w {
-			_, w, _ = detectSequence(td.data[j:])
+			w, _ = parseSequence(td.data[j:])
 		}
 	}
 }
diff --git a/mouse_test.go b/mouse_test.go
index 30f6ee3..69b2730 100644
--- a/mouse_test.go
+++ b/mouse_test.go
@@ -3,205 +3,118 @@ package tea
 import (
 	"fmt"
 	"testing"
+
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/ansi/parser"
 )
 
 func TestMouseEvent_String(t *testing.T) {
 	tt := []struct {
 		name     string
-		event    MouseEvent
+		event    Msg
 		expected string
 	}{
 		{
-			name: "unknown",
-			event: MouseEvent{
-				Action: MouseActionPress,
-				Button: MouseButtonNone,
-				Type:   MouseUnknown,
-			},
+			name:     "unknown",
+			event:    MouseClickMsg{Button: MouseButton(0xff)},
 			expected: "unknown",
 		},
 		{
-			name: "left",
-			event: MouseEvent{
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-				Type:   MouseLeft,
-			},
-			expected: "left press",
-		},
-		{
-			name: "right",
-			event: MouseEvent{
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-				Type:   MouseRight,
-			},
-			expected: "right press",
-		},
-		{
-			name: "middle",
-			event: MouseEvent{
-				Action: MouseActionPress,
-				Button: MouseButtonMiddle,
-				Type:   MouseMiddle,
-			},
-			expected: "middle press",
-		},
-		{
-			name: "release",
-			event: MouseEvent{
-				Action: MouseActionRelease,
-				Button: MouseButtonNone,
-				Type:   MouseRelease,
-			},
-			expected: "release",
-		},
-		{
-			name: "wheel up",
-			event: MouseEvent{
-				Action: MouseActionPress,
-				Button: MouseButtonWheelUp,
-				Type:   MouseWheelUp,
-			},
-			expected: "wheel up",
-		},
-		{
-			name: "wheel down",
-			event: MouseEvent{
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-				Type:   MouseWheelDown,
-			},
-			expected: "wheel down",
-		},
-		{
-			name: "wheel left",
-			event: MouseEvent{
-				Action: MouseActionPress,
-				Button: MouseButtonWheelLeft,
-				Type:   MouseWheelLeft,
-			},
-			expected: "wheel left",
-		},
-		{
-			name: "wheel right",
-			event: MouseEvent{
-				Action: MouseActionPress,
-				Button: MouseButtonWheelRight,
-				Type:   MouseWheelRight,
-			},
-			expected: "wheel right",
-		},
-		{
-			name: "motion",
-			event: MouseEvent{
-				Action: MouseActionMotion,
-				Button: MouseButtonNone,
-				Type:   MouseMotion,
-			},
-			expected: "motion",
+			name:     "left",
+			event:    MouseClickMsg{Button: MouseLeft},
+			expected: "left",
 		},
 		{
-			name: "shift+left release",
-			event: MouseEvent{
-				Type:   MouseLeft,
-				Action: MouseActionRelease,
-				Button: MouseButtonLeft,
-				Shift:  true,
-			},
-			expected: "shift+left release",
-		},
-		{
-			name: "shift+left",
-			event: MouseEvent{
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-				Shift:  true,
-			},
-			expected: "shift+left press",
-		},
-		{
-			name: "ctrl+shift+left",
-			event: MouseEvent{
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-				Shift:  true,
-				Ctrl:   true,
-			},
-			expected: "ctrl+shift+left press",
-		},
-		{
-			name: "alt+left",
-			event: MouseEvent{
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-				Alt:    true,
-			},
-			expected: "alt+left press",
-		},
-		{
-			name: "ctrl+left",
-			event: MouseEvent{
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-				Ctrl:   true,
-			},
-			expected: "ctrl+left press",
-		},
-		{
-			name: "ctrl+alt+left",
-			event: MouseEvent{
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-				Alt:    true,
-				Ctrl:   true,
-			},
-			expected: "ctrl+alt+left press",
-		},
-		{
-			name: "ctrl+alt+shift+left",
-			event: MouseEvent{
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-				Alt:    true,
-				Ctrl:   true,
-				Shift:  true,
-			},
-			expected: "ctrl+alt+shift+left press",
-		},
-		{
-			name: "ignore coordinates",
-			event: MouseEvent{
-				X:      100,
-				Y:      200,
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-			},
-			expected: "left press",
-		},
-		{
-			name: "broken type",
-			event: MouseEvent{
-				Type:   MouseEventType(-100),
-				Action: MouseAction(-110),
-				Button: MouseButton(-120),
-			},
+			name:     "right",
+			event:    MouseClickMsg{Button: MouseRight},
+			expected: "right",
+		},
+		{
+			name:     "middle",
+			event:    MouseClickMsg{Button: MouseMiddle},
+			expected: "middle",
+		},
+		{
+			name:     "release",
+			event:    MouseReleaseMsg{Button: MouseNone},
 			expected: "",
 		},
+		{
+			name:     "wheelup",
+			event:    MouseWheelMsg{Button: MouseWheelUp},
+			expected: "wheelup",
+		},
+		{
+			name:     "wheeldown",
+			event:    MouseWheelMsg{Button: MouseWheelDown},
+			expected: "wheeldown",
+		},
+		{
+			name:     "wheelleft",
+			event:    MouseWheelMsg{Button: MouseWheelLeft},
+			expected: "wheelleft",
+		},
+		{
+			name:     "wheelright",
+			event:    MouseWheelMsg{Button: MouseWheelRight},
+			expected: "wheelright",
+		},
+		{
+			name:     "motion",
+			event:    MouseMotionMsg{Button: MouseNone},
+			expected: "motion",
+		},
+		{
+			name:     "shift+left",
+			event:    MouseReleaseMsg{Button: MouseLeft, Mod: ModShift},
+			expected: "shift+left",
+		},
+		{
+			name: "shift+left", event: MouseClickMsg{Button: MouseLeft, Mod: ModShift},
+			expected: "shift+left",
+		},
+		{
+			name:     "ctrl+shift+left",
+			event:    MouseClickMsg{Button: MouseLeft, Mod: ModCtrl | ModShift},
+			expected: "ctrl+shift+left",
+		},
+		{
+			name:     "alt+left",
+			event:    MouseClickMsg{Button: MouseLeft, Mod: ModAlt},
+			expected: "alt+left",
+		},
+		{
+			name:     "ctrl+left",
+			event:    MouseClickMsg{Button: MouseLeft, Mod: ModCtrl},
+			expected: "ctrl+left",
+		},
+		{
+			name:     "ctrl+alt+left",
+			event:    MouseClickMsg{Button: MouseLeft, Mod: ModAlt | ModCtrl},
+			expected: "ctrl+alt+left",
+		},
+		{
+			name:     "ctrl+alt+shift+left",
+			event:    MouseClickMsg{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift},
+			expected: "ctrl+alt+shift+left",
+		},
+		{
+			name:     "ignore coordinates",
+			event:    MouseClickMsg{X: 100, Y: 200, Button: MouseLeft},
+			expected: "left",
+		},
+		{
+			name:     "broken type",
+			event:    MouseClickMsg{Button: MouseButton(120)},
+			expected: "unknown",
+		},
 	}
 
 	for i := range tt {
 		tc := tt[i]
 
 		t.Run(tc.name, func(t *testing.T) {
-			actual := tc.event.String()
+			actual := fmt.Sprint(tc.event)
 
 			if tc.expected != actual {
 				t.Fatalf("expected %q but got %q",
@@ -213,7 +126,7 @@ func TestMouseEvent_String(t *testing.T) {
 	}
 }
 
-func TestParseX10MouseEvent(t *testing.T) {
+func TestParseX10MouseDownEvent(t *testing.T) {
 	encode := func(b byte, x, y int) []byte {
 		return []byte{
 			'\x1b',
@@ -228,330 +141,151 @@ func TestParseX10MouseEvent(t *testing.T) {
 	tt := []struct {
 		name     string
 		buf      []byte
-		expected MouseEvent
+		expected Msg
 	}{
 		// Position.
 		{
-			name: "zero position",
-			buf:  encode(0b0000_0000, 0, 0),
-			expected: MouseEvent{
-				X:      0,
-				Y:      0,
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-			},
-		},
-		{
-			name: "max position",
-			buf:  encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
-			expected: MouseEvent{
-				X:      222,
-				Y:      222,
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-			},
+			name:     "zero position",
+			buf:      encode(0b0000_0000, 0, 0),
+			expected: MouseClickMsg{X: 0, Y: 0, Button: MouseLeft},
+		},
+		{
+			name:     "max position",
+			buf:      encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
+			expected: MouseClickMsg{X: 222, Y: 222, Button: MouseLeft},
 		},
 		// Simple.
 		{
-			name: "left",
-			buf:  encode(0b0000_0000, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-			},
-		},
-		{
-			name: "left in motion",
-			buf:  encode(0b0010_0000, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseLeft,
-				Action: MouseActionMotion,
-				Button: MouseButtonLeft,
-			},
-		},
-		{
-			name: "middle",
-			buf:  encode(0b0000_0001, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseMiddle,
-				Action: MouseActionPress,
-				Button: MouseButtonMiddle,
-			},
-		},
-		{
-			name: "middle in motion",
-			buf:  encode(0b0010_0001, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseMiddle,
-				Action: MouseActionMotion,
-				Button: MouseButtonMiddle,
-			},
-		},
-		{
-			name: "right",
-			buf:  encode(0b0000_0010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseRight,
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "right in motion",
-			buf:  encode(0b0010_0010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseRight,
-				Action: MouseActionMotion,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "motion",
-			buf:  encode(0b0010_0011, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseMotion,
-				Action: MouseActionMotion,
-				Button: MouseButtonNone,
-			},
-		},
-		{
-			name: "wheel up",
-			buf:  encode(0b0100_0000, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseWheelUp,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelUp,
-			},
-		},
-		{
-			name: "wheel down",
-			buf:  encode(0b0100_0001, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseWheelDown,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-			},
-		},
-		{
-			name: "wheel left",
-			buf:  encode(0b0100_0010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseWheelLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelLeft,
-			},
-		},
-		{
-			name: "wheel right",
-			buf:  encode(0b0100_0011, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseWheelRight,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelRight,
-			},
-		},
-		{
-			name: "release",
-			buf:  encode(0b0000_0011, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseRelease,
-				Action: MouseActionRelease,
-				Button: MouseButtonNone,
-			},
-		},
-		{
-			name: "backward",
-			buf:  encode(0b1000_0000, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseBackward,
-				Action: MouseActionPress,
-				Button: MouseButtonBackward,
-			},
-		},
-		{
-			name: "forward",
-			buf:  encode(0b1000_0001, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseForward,
-				Action: MouseActionPress,
-				Button: MouseButtonForward,
-			},
-		},
-		{
-			name: "button 10",
-			buf:  encode(0b1000_0010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseUnknown,
-				Action: MouseActionPress,
-				Button: MouseButton10,
-			},
-		},
-		{
-			name: "button 11",
-			buf:  encode(0b1000_0011, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseUnknown,
-				Action: MouseActionPress,
-				Button: MouseButton11,
-			},
+			name:     "left",
+			buf:      encode(0b0000_0000, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "left in motion",
+			buf:      encode(0b0010_0000, 32, 16),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "middle",
+			buf:      encode(0b0000_0001, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "middle in motion",
+			buf:      encode(0b0010_0001, 32, 16),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "right",
+			buf:      encode(0b0000_0010, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseRight},
+		},
+		{
+			name:     "right in motion",
+			buf:      encode(0b0010_0010, 32, 16),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseRight},
+		},
+		{
+			name:     "motion",
+			buf:      encode(0b0010_0011, 32, 16),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseNone},
+		},
+		{
+			name:     "wheel up",
+			buf:      encode(0b0100_0000, 32, 16),
+			expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelUp},
+		},
+		{
+			name:     "wheel down",
+			buf:      encode(0b0100_0001, 32, 16),
+			expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelDown},
+		},
+		{
+			name:     "wheel left",
+			buf:      encode(0b0100_0010, 32, 16),
+			expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelLeft},
+		},
+		{
+			name:     "wheel right",
+			buf:      encode(0b0100_0011, 32, 16),
+			expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelRight},
+		},
+		{
+			name:     "release",
+			buf:      encode(0b0000_0011, 32, 16),
+			expected: MouseReleaseMsg{X: 32, Y: 16, Button: MouseNone},
+		},
+		{
+			name:     "backward",
+			buf:      encode(0b1000_0000, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseBackward},
+		},
+		{
+			name:     "forward",
+			buf:      encode(0b1000_0001, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseForward},
+		},
+		{
+			name:     "button 10",
+			buf:      encode(0b1000_0010, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseExtra1},
+		},
+		{
+			name:     "button 11",
+			buf:      encode(0b1000_0011, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseExtra2},
 		},
 		// Combinations.
 		{
-			name: "alt+right",
-			buf:  encode(0b0000_1010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Type:   MouseRight,
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "ctrl+right",
-			buf:  encode(0b0001_0010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Ctrl:   true,
-				Type:   MouseRight,
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "left in motion",
-			buf:  encode(0b0010_0000, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    false,
-				Type:   MouseLeft,
-				Action: MouseActionMotion,
-				Button: MouseButtonLeft,
-			},
-		},
-		{
-			name: "alt+right in motion",
-			buf:  encode(0b0010_1010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Type:   MouseRight,
-				Action: MouseActionMotion,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "ctrl+right in motion",
-			buf:  encode(0b0011_0010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Ctrl:   true,
-				Type:   MouseRight,
-				Action: MouseActionMotion,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "ctrl+alt+right",
-			buf:  encode(0b0001_1010, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Ctrl:   true,
-				Type:   MouseRight,
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "ctrl+wheel up",
-			buf:  encode(0b0101_0000, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Ctrl:   true,
-				Type:   MouseWheelUp,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelUp,
-			},
-		},
-		{
-			name: "alt+wheel down",
-			buf:  encode(0b0100_1001, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Type:   MouseWheelDown,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-			},
-		},
-		{
-			name: "ctrl+alt+wheel down",
-			buf:  encode(0b0101_1001, 32, 16),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Ctrl:   true,
-				Type:   MouseWheelDown,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-			},
+			name:     "alt+right",
+			buf:      encode(0b0000_1010, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+right",
+			buf:      encode(0b0001_0010, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "left in motion",
+			buf:      encode(0b0010_0000, 32, 16),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "alt+right in motion",
+			buf:      encode(0b0010_1010, 32, 16),
+			expected: MouseMotionMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+right in motion",
+			buf:      encode(0b0011_0010, 32, 16),
+			expected: MouseMotionMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+alt+right",
+			buf:      encode(0b0001_1010, 32, 16),
+			expected: MouseClickMsg{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+wheel up",
+			buf:      encode(0b0101_0000, 32, 16),
+			expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp},
+		},
+		{
+			name:     "alt+wheel down",
+			buf:      encode(0b0100_1001, 32, 16),
+			expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
+		},
+		{
+			name:     "ctrl+alt+wheel down",
+			buf:      encode(0b0101_1001, 32, 16),
+			expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
 		},
 		// Overflow position.
 		{
-			name: "overflow position",
-			buf:  encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
-			expected: MouseEvent{
-				X:      -6,
-				Y:      -33,
-				Type:   MouseLeft,
-				Action: MouseActionMotion,
-				Button: MouseButtonLeft,
-			},
+			name:     "overflow position",
+			buf:      encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
+			expected: MouseMotionMsg{X: -6, Y: -33, Button: MouseLeft},
 		},
 	}
 
@@ -571,355 +305,155 @@ func TestParseX10MouseEvent(t *testing.T) {
 	}
 }
 
-// func TestParseX10MouseEvent_error(t *testing.T) {
-// 	tt := []struct {
-// 		name string
-// 		buf  []byte
-// 	}{
-// 		{
-// 			name: "empty buf",
-// 			buf:  nil,
-// 		},
-// 		{
-// 			name: "wrong high bit",
-// 			buf:  []byte("\x1a[M@A1"),
-// 		},
-// 		{
-// 			name: "short buf",
-// 			buf:  []byte("\x1b[M@A"),
-// 		},
-// 		{
-// 			name: "long buf",
-// 			buf:  []byte("\x1b[M@A11"),
-// 		},
-// 	}
-//
-// 	for i := range tt {
-// 		tc := tt[i]
-//
-// 		t.Run(tc.name, func(t *testing.T) {
-// 			_, err := parseX10MouseEvent(tc.buf)
-//
-// 			if err == nil {
-// 				t.Fatalf("expected error but got nil")
-// 			}
-// 		})
-// 	}
-// }
-
 func TestParseSGRMouseEvent(t *testing.T) {
-	encode := func(b, x, y int, r bool) []byte {
+	encode := func(b, x, y int, r bool) *ansi.CsiSequence {
 		re := 'M'
 		if r {
 			re = 'm'
 		}
-		return []byte(fmt.Sprintf("\x1b[<%d;%d;%d%c", b, x+1, y+1, re))
+		return &ansi.CsiSequence{
+			Params: []int{b, x + 1, y + 1},
+			Cmd:    int(re) | ('<' << parser.MarkerShift),
+		}
 	}
 
 	tt := []struct {
 		name     string
-		buf      []byte
-		expected MouseEvent
+		buf      *ansi.CsiSequence
+		expected Msg
 	}{
 		// Position.
 		{
-			name: "zero position",
-			buf:  encode(0, 0, 0, false),
-			expected: MouseEvent{
-				X:      0,
-				Y:      0,
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-			},
-		},
-		{
-			name: "225 position",
-			buf:  encode(0, 225, 225, false),
-			expected: MouseEvent{
-				X:      225,
-				Y:      225,
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-			},
+			name:     "zero position",
+			buf:      encode(0, 0, 0, false),
+			expected: MouseClickMsg{X: 0, Y: 0, Button: MouseLeft},
+		},
+		{
+			name:     "225 position",
+			buf:      encode(0, 225, 225, false),
+			expected: MouseClickMsg{X: 225, Y: 225, Button: MouseLeft},
 		},
 		// Simple.
 		{
-			name: "left",
-			buf:  encode(0, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonLeft,
-			},
-		},
-		{
-			name: "left in motion",
-			buf:  encode(32, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseLeft,
-				Action: MouseActionMotion,
-				Button: MouseButtonLeft,
-			},
-		},
-		{
-			name: "left release",
-			buf:  encode(0, 32, 16, true),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseRelease,
-				Action: MouseActionRelease,
-				Button: MouseButtonLeft,
-			},
-		},
-		{
-			name: "middle",
-			buf:  encode(1, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseMiddle,
-				Action: MouseActionPress,
-				Button: MouseButtonMiddle,
-			},
-		},
-		{
-			name: "middle in motion",
-			buf:  encode(33, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseMiddle,
-				Action: MouseActionMotion,
-				Button: MouseButtonMiddle,
-			},
-		},
-		{
-			name: "middle release",
-			buf:  encode(1, 32, 16, true),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseRelease,
-				Action: MouseActionRelease,
-				Button: MouseButtonMiddle,
-			},
-		},
-		{
-			name: "right",
-			buf:  encode(2, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseRight,
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "right release",
-			buf:  encode(2, 32, 16, true),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseRelease,
-				Action: MouseActionRelease,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "motion",
-			buf:  encode(35, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseMotion,
-				Action: MouseActionMotion,
-				Button: MouseButtonNone,
-			},
-		},
-		{
-			name: "wheel up",
-			buf:  encode(64, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseWheelUp,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelUp,
-			},
-		},
-		{
-			name: "wheel down",
-			buf:  encode(65, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseWheelDown,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-			},
-		},
-		{
-			name: "wheel left",
-			buf:  encode(66, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseWheelLeft,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelLeft,
-			},
-		},
-		{
-			name: "wheel right",
-			buf:  encode(67, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseWheelRight,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelRight,
-			},
-		},
-		{
-			name: "backward",
-			buf:  encode(128, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseBackward,
-				Action: MouseActionPress,
-				Button: MouseButtonBackward,
-			},
-		},
-		{
-			name: "backward in motion",
-			buf:  encode(160, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseBackward,
-				Action: MouseActionMotion,
-				Button: MouseButtonBackward,
-			},
-		},
-		{
-			name: "forward",
-			buf:  encode(129, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseForward,
-				Action: MouseActionPress,
-				Button: MouseButtonForward,
-			},
-		},
-		{
-			name: "forward in motion",
-			buf:  encode(161, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Type:   MouseForward,
-				Action: MouseActionMotion,
-				Button: MouseButtonForward,
-			},
+			name:     "left",
+			buf:      encode(0, 32, 16, false),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "left in motion",
+			buf:      encode(32, 32, 16, false),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "left",
+			buf:      encode(0, 32, 16, true),
+			expected: MouseReleaseMsg{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "middle",
+			buf:      encode(1, 32, 16, false),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "middle in motion",
+			buf:      encode(33, 32, 16, false),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "middle",
+			buf:      encode(1, 32, 16, true),
+			expected: MouseReleaseMsg{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "right",
+			buf:      encode(2, 32, 16, false),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseRight},
+		},
+		{
+			name:     "right",
+			buf:      encode(2, 32, 16, true),
+			expected: MouseReleaseMsg{X: 32, Y: 16, Button: MouseRight},
+		},
+		{
+			name:     "motion",
+			buf:      encode(35, 32, 16, false),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseNone},
+		},
+		{
+			name:     "wheel up",
+			buf:      encode(64, 32, 16, false),
+			expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelUp},
+		},
+		{
+			name:     "wheel down",
+			buf:      encode(65, 32, 16, false),
+			expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelDown},
+		},
+		{
+			name:     "wheel left",
+			buf:      encode(66, 32, 16, false),
+			expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelLeft},
+		},
+		{
+			name:     "wheel right",
+			buf:      encode(67, 32, 16, false),
+			expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelRight},
+		},
+		{
+			name:     "backward",
+			buf:      encode(128, 32, 16, false),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseBackward},
+		},
+		{
+			name:     "backward in motion",
+			buf:      encode(160, 32, 16, false),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseBackward},
+		},
+		{
+			name:     "forward",
+			buf:      encode(129, 32, 16, false),
+			expected: MouseClickMsg{X: 32, Y: 16, Button: MouseForward},
+		},
+		{
+			name:     "forward in motion",
+			buf:      encode(161, 32, 16, false),
+			expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseForward},
 		},
 		// Combinations.
 		{
-			name: "alt+right",
-			buf:  encode(10, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Type:   MouseRight,
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "ctrl+right",
-			buf:  encode(18, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Ctrl:   true,
-				Type:   MouseRight,
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "ctrl+alt+right",
-			buf:  encode(26, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Ctrl:   true,
-				Type:   MouseRight,
-				Action: MouseActionPress,
-				Button: MouseButtonRight,
-			},
-		},
-		{
-			name: "alt+wheel press",
-			buf:  encode(73, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Type:   MouseWheelDown,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-			},
-		},
-		{
-			name: "ctrl+wheel press",
-			buf:  encode(81, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Ctrl:   true,
-				Type:   MouseWheelDown,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-			},
-		},
-		{
-			name: "ctrl+alt+wheel press",
-			buf:  encode(89, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Alt:    true,
-				Ctrl:   true,
-				Type:   MouseWheelDown,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-			},
-		},
-		{
-			name: "ctrl+alt+shift+wheel press",
-			buf:  encode(93, 32, 16, false),
-			expected: MouseEvent{
-				X:      32,
-				Y:      16,
-				Shift:  true,
-				Alt:    true,
-				Ctrl:   true,
-				Type:   MouseWheelDown,
-				Action: MouseActionPress,
-				Button: MouseButtonWheelDown,
-			},
+			name:     "alt+right",
+			buf:      encode(10, 32, 16, false),
+			expected: MouseClickMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+right",
+			buf:      encode(18, 32, 16, false),
+			expected: MouseClickMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+alt+right",
+			buf:      encode(26, 32, 16, false),
+			expected: MouseClickMsg{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "alt+wheel",
+			buf:      encode(73, 32, 16, false),
+			expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
+		},
+		{
+			name:     "ctrl+wheel",
+			buf:      encode(81, 32, 16, false),
+			expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown},
+		},
+		{
+			name:     "ctrl+alt+wheel",
+			buf:      encode(89, 32, 16, false),
+			expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
+		},
+		{
+			name:     "ctrl+alt+shift+wheel",
+			buf:      encode(93, 32, 16, false),
+			expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown},
 		},
 	}
 
diff --git a/nil_renderer_test.go b/nil_renderer_test.go
index ab94e34..f3db195 100644
--- a/nil_renderer_test.go
+++ b/nil_renderer_test.go
@@ -17,8 +17,5 @@ func TestNilRenderer(t *testing.T) {
 	r.clearScreen()
 	r.showCursor()
 	r.hideCursor()
-	r.enableMouseCellMotion()
-	r.disableMouseCellMotion()
-	r.enableMouseAllMotion()
-	r.disableMouseAllMotion()
+	r.execute("")
 }
diff --git a/parse_test.go b/parse_test.go
new file mode 100644
index 0000000..6e8e0b6
--- /dev/null
+++ b/parse_test.go
@@ -0,0 +1,41 @@
+package tea
+
+import (
+	"image/color"
+	"reflect"
+	"testing"
+)
+
+func TestParseSequence_Events(t *testing.T) {
+	input := []byte("\x1b\x1b[Ztest\x00\x1b]10;rgb:1234/1234/1234\x07\x1b[27;2;27~\x1b[?1049;2$y")
+	want := []Msg{
+		KeyPressMsg{Type: KeyTab, Mod: ModShift | ModAlt},
+		KeyPressMsg{Type: KeyRunes, Runes: []rune{'t'}},
+		KeyPressMsg{Type: KeyRunes, Runes: []rune{'e'}},
+		KeyPressMsg{Type: KeyRunes, Runes: []rune{'s'}},
+		KeyPressMsg{Type: KeyRunes, Runes: []rune{'t'}},
+		KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModCtrl},
+		ForegroundColorMsg{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}},
+		KeyPressMsg{Type: KeyEscape, Mod: ModShift},
+		ReportModeMsg{Mode: 1049, Value: 2},
+	}
+	for i := 0; len(input) != 0; i++ {
+		if i >= len(want) {
+			t.Fatalf("reached end of want events")
+		}
+		n, got := parseSequence(input)
+		if !reflect.DeepEqual(got, want[i]) {
+			t.Errorf("got %v (%T), want %v (%T)", got, got, want[i], want[i])
+		}
+		input = input[n:]
+	}
+}
+
+func BenchmarkParseSequence(b *testing.B) {
+	input := []byte("\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~")
+	b.ReportAllocs()
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		parseSequence(input)
+	}
+}