Просмотр исходного кода

fix(tui): mouse wheel ansi codes leaking into editor

adamdotdevin 7 месяцев назад
Родитель
Сommit
294d0e7ee3
35 измененных файлов с 6104 добавлено и 61 удалено
  1. 1 1
      packages/tui/cmd/opencode/main.go
  2. 9 6
      packages/tui/go.mod
  3. 8 10
      packages/tui/go.sum
  4. 14 0
      packages/tui/input/cancelreader_other.go
  5. 143 0
      packages/tui/input/cancelreader_windows.go
  6. 25 0
      packages/tui/input/clipboard.go
  7. 136 0
      packages/tui/input/color.go
  8. 7 0
      packages/tui/input/cursor.go
  9. 18 0
      packages/tui/input/da1.go
  10. 6 0
      packages/tui/input/doc.go
  11. 196 0
      packages/tui/input/driver.go
  12. 17 0
      packages/tui/input/driver_other.go
  13. 25 0
      packages/tui/input/driver_test.go
  14. 620 0
      packages/tui/input/driver_windows.go
  15. 271 0
      packages/tui/input/driver_windows_test.go
  16. 9 0
      packages/tui/input/focus.go
  17. 27 0
      packages/tui/input/focus_test.go
  18. 18 0
      packages/tui/input/go.mod
  19. 19 0
      packages/tui/input/go.sum
  20. 45 0
      packages/tui/input/input.go
  21. 574 0
      packages/tui/input/key.go
  22. 880 0
      packages/tui/input/key_test.go
  23. 353 0
      packages/tui/input/kitty.go
  24. 37 0
      packages/tui/input/mod.go
  25. 14 0
      packages/tui/input/mode.go
  26. 292 0
      packages/tui/input/mouse.go
  27. 481 0
      packages/tui/input/mouse_test.go
  28. 1029 0
      packages/tui/input/parse.go
  29. 47 0
      packages/tui/input/parse_test.go
  30. 13 0
      packages/tui/input/paste.go
  31. 389 0
      packages/tui/input/table.go
  32. 54 0
      packages/tui/input/termcap.go
  33. 277 0
      packages/tui/input/terminfo.go
  34. 47 0
      packages/tui/input/xterm.go
  35. 3 44
      packages/tui/internal/tui/tui.go

+ 1 - 1
packages/tui/cmd/opencode/main.go

@@ -77,7 +77,7 @@ func main() {
 	program := tea.NewProgram(
 		tui.NewModel(app_),
 		tea.WithAltScreen(),
-		tea.WithKeyboardEnhancements(),
+		// tea.WithKeyboardEnhancements(),
 		tea.WithMouseCellMotion(),
 	)
 

+ 9 - 6
packages/tui/go.mod

@@ -6,10 +6,11 @@ require (
 	github.com/BurntSushi/toml v1.5.0
 	github.com/alecthomas/chroma/v2 v2.18.0
 	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
-	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
+	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
 	github.com/charmbracelet/glamour v0.10.0
-	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
-	github.com/charmbracelet/x/ansi v0.8.0
+	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
+	github.com/charmbracelet/x/ansi v0.9.3
+	github.com/charmbracelet/x/input v0.3.7
 	github.com/google/uuid v1.6.0
 	github.com/lithammer/fuzzysearch v1.1.8
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
@@ -21,7 +22,10 @@ require (
 	rsc.io/qr v0.2.0
 )
 
-replace github.com/sst/opencode-sdk-go => ./sdk
+replace (
+	github.com/charmbracelet/x/input => ./input
+	github.com/sst/opencode-sdk-go => ./sdk
+)
 
 require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
 
@@ -30,7 +34,6 @@ require (
 	github.com/atombender/go-jsonschema v0.20.0 // indirect
 	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
 	github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
-	github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
 	github.com/charmbracelet/x/windows v0.2.1 // indirect
 	github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
 	github.com/fsnotify/fsnotify v1.8.0 // indirect
@@ -65,7 +68,7 @@ require (
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/colorprofile v0.3.1 // indirect
-	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect

+ 8 - 10
packages/tui/go.sum

@@ -22,26 +22,24 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
 github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
 github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
-github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
-github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81/go.mod h1:poPFOXFTsJsnLbkV3H2KxAAXT7pdjxxLujLocWjkyzM=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
+github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
+github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
-github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 h1:fsWj8NF5njyMVzELc7++HsvRDvgz3VcgGAUgWBDWWWM=
-github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197/go.mod h1:xseGeVftoP9rVI+/8WKYrJFH6ior6iERGvklwwHz5+s=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=

+ 14 - 0
packages/tui/input/cancelreader_other.go

@@ -0,0 +1,14 @@
+//go:build !windows
+// +build !windows
+
+package input
+
+import (
+	"io"
+
+	"github.com/muesli/cancelreader"
+)
+
+func newCancelreader(r io.Reader, _ int) (cancelreader.CancelReader, error) {
+	return cancelreader.NewReader(r) //nolint:wrapcheck
+}

+ 143 - 0
packages/tui/input/cancelreader_windows.go

@@ -0,0 +1,143 @@
+//go:build windows
+// +build windows
+
+package input
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"sync"
+
+	xwindows "github.com/charmbracelet/x/windows"
+	"github.com/muesli/cancelreader"
+	"golang.org/x/sys/windows"
+)
+
+type conInputReader struct {
+	cancelMixin
+	conin        windows.Handle
+	originalMode uint32
+}
+
+var _ cancelreader.CancelReader = &conInputReader{}
+
+func newCancelreader(r io.Reader, flags int) (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 := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
+	if err != nil {
+		return fallback(r)
+	}
+
+	// Discard any pending input events.
+	if err := xwindows.FlushConsoleInputBuffer(conin); err != nil {
+		return fallback(r)
+	}
+
+	modes := []uint32{
+		windows.ENABLE_WINDOW_INPUT,
+		windows.ENABLE_EXTENDED_FLAGS,
+	}
+
+	// Enabling mouse mode implicitly blocks console text selection. Thus, we
+	// need to enable it only if the mouse mode is requested.
+	// In order to toggle mouse mode, the caller must recreate the reader with
+	// the appropriate flag toggled.
+	if flags&FlagMouseMode != 0 {
+		modes = append(modes, windows.ENABLE_MOUSE_INPUT)
+	}
+
+	originalMode, err := prepareConsole(conin, modes...)
+	if err != nil {
+		return nil, fmt.Errorf("failed to prepare console input: %w", err)
+	}
+
+	return &conInputReader{
+		conin:        conin,
+		originalMode: originalMode,
+	}, nil
+}
+
+// Cancel implements cancelreader.CancelReader.
+func (r *conInputReader) Cancel() bool {
+	r.setCanceled()
+
+	return windows.CancelIoEx(r.conin, nil) == nil || 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 (r *conInputReader) Read(data []byte) (int, error) {
+	if r.isCanceled() {
+		return 0, cancelreader.ErrCanceled
+	}
+
+	var n uint32
+	if err := windows.ReadFile(r.conin, data, &n, nil); err != nil {
+		return int(n), fmt.Errorf("read console input: %w", err)
+	}
+
+	return int(n), 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)
+	}
+
+	var newMode uint32
+	for _, mode := range modes {
+		newMode |= mode
+	}
+
+	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
+}
+
+func (c *cancelMixin) isCanceled() bool {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	return c.unsafeCanceled
+}

+ 25 - 0
packages/tui/input/clipboard.go

@@ -0,0 +1,25 @@
+package input
+
+import "github.com/charmbracelet/x/ansi"
+
+// ClipboardSelection represents a clipboard selection. The most common
+// clipboard selections are "system" and "primary" and selections.
+type ClipboardSelection = byte
+
+// Clipboard selections.
+const (
+	SystemClipboard  ClipboardSelection = ansi.SystemClipboard
+	PrimaryClipboard ClipboardSelection = ansi.PrimaryClipboard
+)
+
+// ClipboardEvent is a clipboard read message event. This message is emitted when
+// a terminal receives an OSC52 clipboard read message event.
+type ClipboardEvent struct {
+	Content   string
+	Selection ClipboardSelection
+}
+
+// String returns the string representation of the clipboard message.
+func (e ClipboardEvent) String() string {
+	return e.Content
+}

+ 136 - 0
packages/tui/input/color.go

@@ -0,0 +1,136 @@
+package input
+
+import (
+	"fmt"
+	"image/color"
+	"math"
+)
+
+// ForegroundColorEvent represents a foreground color event. This event is
+// emitted when the terminal requests the terminal foreground color using
+// [ansi.RequestForegroundColor].
+type ForegroundColorEvent struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e ForegroundColorEvent) String() string {
+	return colorToHex(e.Color)
+}
+
+// IsDark returns whether the color is dark.
+func (e ForegroundColorEvent) IsDark() bool {
+	return isDarkColor(e.Color)
+}
+
+// BackgroundColorEvent represents a background color event. This event is
+// emitted when the terminal requests the terminal background color using
+// [ansi.RequestBackgroundColor].
+type BackgroundColorEvent struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e BackgroundColorEvent) String() string {
+	return colorToHex(e)
+}
+
+// IsDark returns whether the color is dark.
+func (e BackgroundColorEvent) IsDark() bool {
+	return isDarkColor(e.Color)
+}
+
+// CursorColorEvent represents a cursor color change event. This event is
+// emitted when the program requests the terminal cursor color using
+// [ansi.RequestCursorColor].
+type CursorColorEvent struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e CursorColorEvent) String() string {
+	return colorToHex(e)
+}
+
+// IsDark returns whether the color is dark.
+func (e CursorColorEvent) IsDark() bool {
+	return isDarkColor(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 {
+	if c == nil {
+		return ""
+	}
+	r, g, b, _ := c.RGBA()
+	return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b))
+}
+
+func getMaxMin(a, b, c float64) (ma, mi float64) {
+	if a > b {
+		ma = a
+		mi = b
+	} else {
+		ma = b
+		mi = a
+	}
+	if c > ma {
+		ma = c
+	} else if c < mi {
+		mi = c
+	}
+	return ma, mi
+}
+
+func round(x float64) float64 {
+	return math.Round(x*1000) / 1000
+}
+
+// rgbToHSL converts an RGB triple to an HSL triple.
+func rgbToHSL(r, g, b uint8) (h, s, l float64) {
+	// convert uint32 pre-multiplied value to uint8
+	// The r,g,b values are divided by 255 to change the range from 0..255 to 0..1:
+	Rnot := float64(r) / 255
+	Gnot := float64(g) / 255
+	Bnot := float64(b) / 255
+	Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot)
+	Δ := Cmax - Cmin
+	// Lightness calculation:
+	l = (Cmax + Cmin) / 2
+	// Hue and Saturation Calculation:
+	if Δ == 0 {
+		h = 0
+		s = 0
+	} else {
+		switch Cmax {
+		case Rnot:
+			h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
+		case Gnot:
+			h = 60 * (((Bnot - Rnot) / Δ) + 2)
+		case Bnot:
+			h = 60 * (((Rnot - Gnot) / Δ) + 4)
+		}
+		if h < 0 {
+			h += 360
+		}
+
+		s = Δ / (1 - math.Abs((2*l)-1))
+	}
+
+	return h, round(s), round(l)
+}
+
+// isDarkColor returns whether the given color is dark.
+func isDarkColor(c color.Color) bool {
+	if c == nil {
+		return true
+	}
+
+	r, g, b, _ := c.RGBA()
+	_, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint:gosec
+	return l < 0.5
+}

+ 7 - 0
packages/tui/input/cursor.go

@@ -0,0 +1,7 @@
+package input
+
+import "image"
+
+// CursorPositionEvent represents a cursor position event. Where X is the
+// zero-based column and Y is the zero-based row.
+type CursorPositionEvent image.Point

+ 18 - 0
packages/tui/input/da1.go

@@ -0,0 +1,18 @@
+package input
+
+import "github.com/charmbracelet/x/ansi"
+
+// PrimaryDeviceAttributesEvent is an event that represents the terminal
+// primary device attributes.
+type PrimaryDeviceAttributesEvent []int
+
+func parsePrimaryDevAttrs(params ansi.Params) Event {
+	// Primary Device Attributes
+	da1 := make(PrimaryDeviceAttributesEvent, len(params))
+	for i, p := range params {
+		if !p.HasMore() {
+			da1[i] = p.Param(0)
+		}
+	}
+	return da1
+}

+ 6 - 0
packages/tui/input/doc.go

@@ -0,0 +1,6 @@
+// Package input provides a set of utilities for handling input events in a
+// terminal environment. It includes support for reading input events, parsing
+// escape sequences, and handling clipboard events.
+// The package is designed to work with various terminal types and supports
+// customization through flags and options.
+package input

+ 196 - 0
packages/tui/input/driver.go

@@ -0,0 +1,196 @@
+//nolint:unused,revive,nolintlint
+package input
+
+import (
+	"bytes"
+	"io"
+	"unicode/utf8"
+
+	"github.com/muesli/cancelreader"
+)
+
+// Logger is a simple logger interface.
+type Logger interface {
+	Printf(format string, v ...any)
+}
+
+// win32InputState is a state machine for parsing key events from the Windows
+// Console API into escape sequences and utf8 runes, and keeps track of the last
+// control key state to determine modifier key changes. It also keeps track of
+// the last mouse button state and window size changes to determine which mouse
+// buttons were released and to prevent multiple size events from firing.
+type win32InputState struct {
+	ansiBuf                    [256]byte
+	ansiIdx                    int
+	utf16Buf                   [2]rune
+	utf16Half                  bool
+	lastCks                    uint32 // the last control key state for the previous event
+	lastMouseBtns              uint32 // the last mouse button state for the previous event
+	lastWinsizeX, lastWinsizeY int16  // the last window size for the previous event to prevent multiple size events from firing
+}
+
+// Reader represents an input event reader. It reads input events and parses
+// escape sequences from the terminal input buffer and translates them into
+// human-readable events.
+type Reader 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?
+
+	// partialSeq holds incomplete escape sequences that need more data
+	partialSeq []byte
+
+	// keyState keeps track of the current Windows Console API key events state.
+	// It is used to decode ANSI escape sequences and utf16 sequences.
+	keyState win32InputState
+
+	parser Parser
+	logger Logger
+}
+
+// NewReader returns a new input event reader. The reader reads input events
+// from the terminal and parses escape sequences into human-readable events. It
+// supports reading Terminfo databases. See [Parser] for more information.
+//
+// Example:
+//
+//	r, _ := input.NewReader(os.Stdin, os.Getenv("TERM"), 0)
+//	defer r.Close()
+//	events, _ := r.ReadEvents()
+//	for _, ev := range events {
+//	  log.Printf("%v", ev)
+//	}
+func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
+	d := new(Reader)
+	cr, err := newCancelreader(r, flags)
+	if err != nil {
+		return nil, err
+	}
+
+	d.rd = cr
+	d.table = buildKeysTable(flags, termType)
+	d.term = termType
+	d.parser.flags = flags
+	return d, nil
+}
+
+// SetLogger sets a logger for the reader.
+func (d *Reader) SetLogger(l Logger) {
+	d.logger = l
+}
+
+// Read implements [io.Reader].
+func (d *Reader) Read(p []byte) (int, error) {
+	return d.rd.Read(p) //nolint:wrapcheck
+}
+
+// Cancel cancels the underlying reader.
+func (d *Reader) Cancel() bool {
+	return d.rd.Cancel()
+}
+
+// Close closes the underlying reader.
+func (d *Reader) Close() error {
+	return d.rd.Close() //nolint:wrapcheck
+}
+
+func (d *Reader) readEvents() ([]Event, error) {
+	nb, err := d.rd.Read(d.buf[:])
+	if err != nil {
+		return nil, err //nolint:wrapcheck
+	}
+
+	var events []Event
+
+	// Combine any partial sequence from previous read with new data
+	var buf []byte
+	if len(d.partialSeq) > 0 {
+		buf = make([]byte, len(d.partialSeq)+nb)
+		copy(buf, d.partialSeq)
+		copy(buf[len(d.partialSeq):], d.buf[:nb])
+		d.partialSeq = nil // clear the partial sequence
+	} else {
+		buf = d.buf[:nb]
+	}
+
+	// Lookup table first
+	if bytes.HasPrefix(buf, []byte{'\x1b'}) {
+		if k, ok := d.table[string(buf)]; ok {
+			if d.logger != nil {
+				d.logger.Printf("input: %q", buf)
+			}
+			events = append(events, KeyPressEvent(k))
+			return events, nil
+		}
+	}
+
+	var i int
+	for i < len(buf) {
+		nb, ev := d.parser.parseSequence(buf[i:])
+		if d.logger != nil && nb > 0 {
+			d.logger.Printf("input: %q", buf[i:i+nb])
+		}
+
+		// Handle incomplete sequences - when parseSequence returns (0, nil)
+		// it means we need more data to complete the sequence
+		if nb == 0 && ev == nil {
+			// Store the remaining data for the next read
+			remaining := len(buf) - i
+			if remaining > 0 {
+				d.partialSeq = make([]byte, remaining)
+				copy(d.partialSeq, buf[i:])
+			}
+			break
+		}
+
+		// Handle bracketed-paste
+		if d.paste != nil {
+			if _, ok := ev.(PasteEndEvent); !ok {
+				d.paste = append(d.paste, buf[i])
+				i++
+				continue
+			}
+		}
+
+		switch ev.(type) {
+		case UnknownEvent:
+			// 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 = KeyPressEvent(k)
+			}
+		case PasteStartEvent:
+			d.paste = []byte{}
+		case PasteEndEvent:
+			// 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
+			events = append(events, PasteEvent(paste))
+		case nil:
+			i++
+			continue
+		}
+
+		if mevs, ok := ev.(MultiEvent); ok {
+			events = append(events, []Event(mevs)...)
+		} else {
+			events = append(events, ev)
+		}
+		i += nb
+	}
+
+	return events, nil
+}

+ 17 - 0
packages/tui/input/driver_other.go

@@ -0,0 +1,17 @@
+//go:build !windows
+// +build !windows
+
+package input
+
+// ReadEvents reads input events from the terminal.
+//
+// It reads the events available in the input buffer and returns them.
+func (d *Reader) ReadEvents() ([]Event, error) {
+	return d.readEvents()
+}
+
+// parseWin32InputKeyEvent parses a Win32 input key events. This function is
+// only available on Windows.
+func (p *Parser) parseWin32InputKeyEvent(*win32InputState, uint16, uint16, rune, bool, uint32, uint16) Event {
+	return nil
+}

+ 25 - 0
packages/tui/input/driver_test.go

@@ -0,0 +1,25 @@
+package input
+
+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 := NewReader(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)
+		}
+	}
+}

+ 620 - 0
packages/tui/input/driver_windows.go

@@ -0,0 +1,620 @@
+//go:build windows
+// +build windows
+
+package input
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+	"unicode"
+	"unicode/utf16"
+	"unicode/utf8"
+
+	"github.com/charmbracelet/x/ansi"
+	xwindows "github.com/charmbracelet/x/windows"
+	"github.com/muesli/cancelreader"
+	"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 *Reader) ReadEvents() ([]Event, error) {
+	events, err := d.handleConInput()
+	if errors.Is(err, errNotConInputReader) {
+		return d.readEvents()
+	}
+	return events, err
+}
+
+var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader")
+
+func (d *Reader) handleConInput() ([]Event, error) {
+	cc, ok := d.rd.(*conInputReader)
+	if !ok {
+		return nil, errNotConInputReader
+	}
+
+	var (
+		events []xwindows.InputRecord
+		err    error
+	)
+	for {
+		// Peek up to 256 events, this is to allow for sequences events reported as
+		// key events.
+		events, err = peekNConsoleInputs(cc.conin, 256)
+		if cc.isCanceled() {
+			return nil, cancelreader.ErrCanceled
+		}
+		if err != nil {
+			return nil, fmt.Errorf("peek coninput events: %w", err)
+		}
+		if len(events) > 0 {
+			break
+		}
+
+		// Sleep for a bit to avoid busy waiting.
+		time.Sleep(10 * time.Millisecond)
+	}
+
+	events, err = readNConsoleInputs(cc.conin, uint32(len(events)))
+	if cc.isCanceled() {
+		return nil, cancelreader.ErrCanceled
+	}
+	if err != nil {
+		return nil, fmt.Errorf("read coninput events: %w", err)
+	}
+
+	var evs []Event
+	for _, event := range events {
+		if e := d.parser.parseConInputEvent(event, &d.keyState); e != nil {
+			if multi, ok := e.(MultiEvent); ok {
+				evs = append(evs, multi...)
+			} else {
+				evs = append(evs, e)
+			}
+		}
+	}
+
+	return evs, nil
+}
+
+func (p *Parser) parseConInputEvent(event xwindows.InputRecord, keyState *win32InputState) Event {
+	switch event.EventType {
+	case xwindows.KEY_EVENT:
+		kevent := event.KeyEvent()
+		return p.parseWin32InputKeyEvent(keyState, kevent.VirtualKeyCode, kevent.VirtualScanCode,
+			kevent.Char, kevent.KeyDown, kevent.ControlKeyState, kevent.RepeatCount)
+
+	case xwindows.WINDOW_BUFFER_SIZE_EVENT:
+		wevent := event.WindowBufferSizeEvent()
+		if wevent.Size.X != keyState.lastWinsizeX || wevent.Size.Y != keyState.lastWinsizeY {
+			keyState.lastWinsizeX, keyState.lastWinsizeY = wevent.Size.X, wevent.Size.Y
+			return WindowSizeEvent{
+				Width:  int(wevent.Size.X),
+				Height: int(wevent.Size.Y),
+			}
+		}
+	case xwindows.MOUSE_EVENT:
+		mevent := event.MouseEvent()
+		Event := mouseEvent(keyState.lastMouseBtns, mevent)
+		keyState.lastMouseBtns = mevent.ButtonState
+		return Event
+	case xwindows.FOCUS_EVENT:
+		fevent := event.FocusEvent()
+		if fevent.SetFocus {
+			return FocusEvent{}
+		}
+		return BlurEvent{}
+	case xwindows.MENU_EVENT:
+		// ignore
+	}
+	return nil
+}
+
+func mouseEventButton(p, s uint32) (MouseButton, bool) {
+	var isRelease bool
+	button := MouseNone
+	btn := p ^ s
+	if btn&s == 0 {
+		isRelease = true
+	}
+
+	if btn == 0 {
+		switch {
+		case s&xwindows.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
+			button = MouseLeft
+		case s&xwindows.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
+			button = MouseMiddle
+		case s&xwindows.RIGHTMOST_BUTTON_PRESSED > 0:
+			button = MouseRight
+		case s&xwindows.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
+			button = MouseBackward
+		case s&xwindows.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
+			button = MouseForward
+		}
+		return button, isRelease
+	}
+
+	switch btn {
+	case xwindows.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
+		button = MouseLeft
+	case xwindows.RIGHTMOST_BUTTON_PRESSED: // right button
+		button = MouseRight
+	case xwindows.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
+		button = MouseMiddle
+	case xwindows.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
+		button = MouseBackward
+	case xwindows.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
+		button = MouseForward
+	}
+
+	return button, isRelease
+}
+
+func mouseEvent(p uint32, e xwindows.MouseEventRecord) (ev Event) {
+	var mod KeyMod
+	var isRelease bool
+	if e.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0 {
+		mod |= ModAlt
+	}
+	if e.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0 {
+		mod |= ModCtrl
+	}
+	if e.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0 {
+		mod |= ModShift
+	}
+
+	m := Mouse{
+		X:   int(e.MousePositon.X),
+		Y:   int(e.MousePositon.Y),
+		Mod: mod,
+	}
+
+	wheelDirection := int16(highWord(e.ButtonState)) //nolint:gosec
+	switch e.EventFlags {
+	case 0, xwindows.DOUBLE_CLICK:
+		m.Button, isRelease = mouseEventButton(p, e.ButtonState)
+	case xwindows.MOUSE_WHEELED:
+		if wheelDirection > 0 {
+			m.Button = MouseWheelUp
+		} else {
+			m.Button = MouseWheelDown
+		}
+	case xwindows.MOUSE_HWHEELED:
+		if wheelDirection > 0 {
+			m.Button = MouseWheelRight
+		} else {
+			m.Button = MouseWheelLeft
+		}
+	case xwindows.MOUSE_MOVED:
+		m.Button, _ = mouseEventButton(p, e.ButtonState)
+		return MouseMotionEvent(m)
+	}
+
+	if isWheel(m.Button) {
+		return MouseWheelEvent(m)
+	} else if isRelease {
+		return MouseReleaseEvent(m)
+	}
+
+	return MouseClickEvent(m)
+}
+
+func highWord(data uint32) uint16 {
+	return uint16((data & 0xFFFF0000) >> 16) //nolint:gosec
+}
+
+func readNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
+	if maxEvents == 0 {
+		return nil, fmt.Errorf("maxEvents cannot be zero")
+	}
+
+	records := make([]xwindows.InputRecord, maxEvents)
+	n, err := readConsoleInput(console, records)
+	return records[:n], err
+}
+
+func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
+	if len(inputRecords) == 0 {
+		return 0, fmt.Errorf("size of input record buffer cannot be zero")
+	}
+
+	var read uint32
+
+	err := xwindows.ReadConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
+
+	return read, err //nolint:wrapcheck
+}
+
+func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
+	if len(inputRecords) == 0 {
+		return 0, fmt.Errorf("size of input record buffer cannot be zero")
+	}
+
+	var read uint32
+
+	err := xwindows.PeekConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
+
+	return read, err //nolint:wrapcheck
+}
+
+func peekNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
+	if maxEvents == 0 {
+		return nil, fmt.Errorf("maxEvents cannot be zero")
+	}
+
+	records := make([]xwindows.InputRecord, maxEvents)
+	n, err := peekConsoleInput(console, records)
+	return records[:n], err
+}
+
+// parseWin32InputKeyEvent parses a single key event from either the Windows
+// Console API or win32-input-mode events. When state is nil, it means this is
+// an event from win32-input-mode. Otherwise, it's a key event from the Windows
+// Console API and needs a state to decode ANSI escape sequences and utf16
+// runes.
+func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) (event Event) {
+	defer func() {
+		// Respect the repeat count.
+		if repeatCount > 1 {
+			var multi MultiEvent
+			for i := 0; i < int(repeatCount); i++ {
+				multi = append(multi, event)
+			}
+			event = multi
+		}
+	}()
+	if state != nil {
+		defer func() {
+			state.lastCks = cks
+		}()
+	}
+
+	var utf8Buf [utf8.UTFMax]byte
+	var key Key
+	if state != nil && state.utf16Half {
+		state.utf16Half = false
+		state.utf16Buf[1] = r
+		codepoint := utf16.DecodeRune(state.utf16Buf[0], state.utf16Buf[1])
+		rw := utf8.EncodeRune(utf8Buf[:], codepoint)
+		r, _ = utf8.DecodeRune(utf8Buf[:rw])
+		key.Code = r
+		key.Text = string(r)
+		key.Mod = translateControlKeyState(cks)
+		key = ensureKeyCase(key, cks)
+		if keyDown {
+			return KeyPressEvent(key)
+		}
+		return KeyReleaseEvent(key)
+	}
+
+	var baseCode rune
+	switch {
+	case vkc == 0:
+		// Zero means this event is either an escape code or a unicode
+		// codepoint.
+		if state != nil && state.ansiIdx == 0 && r != ansi.ESC {
+			// This is a unicode codepoint.
+			baseCode = r
+			break
+		}
+
+		if state != nil {
+			// Collect ANSI escape code.
+			state.ansiBuf[state.ansiIdx] = byte(r)
+			state.ansiIdx++
+			if state.ansiIdx <= 2 {
+				// We haven't received enough bytes to determine if this is an
+				// ANSI escape code.
+				return nil
+			}
+			if r == ansi.ESC {
+				// We're expecting a closing String Terminator [ansi.ST].
+				return nil
+			}
+
+			n, event := p.parseSequence(state.ansiBuf[:state.ansiIdx])
+			if n == 0 {
+				return nil
+			}
+			if _, ok := event.(UnknownEvent); ok {
+				return nil
+			}
+
+			state.ansiIdx = 0
+			return event
+		}
+	case vkc == xwindows.VK_BACK:
+		baseCode = KeyBackspace
+	case vkc == xwindows.VK_TAB:
+		baseCode = KeyTab
+	case vkc == xwindows.VK_RETURN:
+		baseCode = KeyEnter
+	case vkc == xwindows.VK_SHIFT:
+		//nolint:nestif
+		if cks&xwindows.SHIFT_PRESSED != 0 {
+			if cks&xwindows.ENHANCED_KEY != 0 {
+				baseCode = KeyRightShift
+			} else {
+				baseCode = KeyLeftShift
+			}
+		} else if state != nil {
+			if state.lastCks&xwindows.SHIFT_PRESSED != 0 {
+				if state.lastCks&xwindows.ENHANCED_KEY != 0 {
+					baseCode = KeyRightShift
+				} else {
+					baseCode = KeyLeftShift
+				}
+			}
+		}
+	case vkc == xwindows.VK_CONTROL:
+		if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
+			baseCode = KeyLeftCtrl
+		} else if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
+			baseCode = KeyRightCtrl
+		} else if state != nil {
+			if state.lastCks&xwindows.LEFT_CTRL_PRESSED != 0 {
+				baseCode = KeyLeftCtrl
+			} else if state.lastCks&xwindows.RIGHT_CTRL_PRESSED != 0 {
+				baseCode = KeyRightCtrl
+			}
+		}
+	case vkc == xwindows.VK_MENU:
+		if cks&xwindows.LEFT_ALT_PRESSED != 0 {
+			baseCode = KeyLeftAlt
+		} else if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
+			baseCode = KeyRightAlt
+		} else if state != nil {
+			if state.lastCks&xwindows.LEFT_ALT_PRESSED != 0 {
+				baseCode = KeyLeftAlt
+			} else if state.lastCks&xwindows.RIGHT_ALT_PRESSED != 0 {
+				baseCode = KeyRightAlt
+			}
+		}
+	case vkc == xwindows.VK_PAUSE:
+		baseCode = KeyPause
+	case vkc == xwindows.VK_CAPITAL:
+		baseCode = KeyCapsLock
+	case vkc == xwindows.VK_ESCAPE:
+		baseCode = KeyEscape
+	case vkc == xwindows.VK_SPACE:
+		baseCode = KeySpace
+	case vkc == xwindows.VK_PRIOR:
+		baseCode = KeyPgUp
+	case vkc == xwindows.VK_NEXT:
+		baseCode = KeyPgDown
+	case vkc == xwindows.VK_END:
+		baseCode = KeyEnd
+	case vkc == xwindows.VK_HOME:
+		baseCode = KeyHome
+	case vkc == xwindows.VK_LEFT:
+		baseCode = KeyLeft
+	case vkc == xwindows.VK_UP:
+		baseCode = KeyUp
+	case vkc == xwindows.VK_RIGHT:
+		baseCode = KeyRight
+	case vkc == xwindows.VK_DOWN:
+		baseCode = KeyDown
+	case vkc == xwindows.VK_SELECT:
+		baseCode = KeySelect
+	case vkc == xwindows.VK_SNAPSHOT:
+		baseCode = KeyPrintScreen
+	case vkc == xwindows.VK_INSERT:
+		baseCode = KeyInsert
+	case vkc == xwindows.VK_DELETE:
+		baseCode = KeyDelete
+	case vkc >= '0' && vkc <= '9':
+		baseCode = rune(vkc)
+	case vkc >= 'A' && vkc <= 'Z':
+		// Convert to lowercase.
+		baseCode = rune(vkc) + 32
+	case vkc == xwindows.VK_LWIN:
+		baseCode = KeyLeftSuper
+	case vkc == xwindows.VK_RWIN:
+		baseCode = KeyRightSuper
+	case vkc == xwindows.VK_APPS:
+		baseCode = KeyMenu
+	case vkc >= xwindows.VK_NUMPAD0 && vkc <= xwindows.VK_NUMPAD9:
+		baseCode = rune(vkc-xwindows.VK_NUMPAD0) + KeyKp0
+	case vkc == xwindows.VK_MULTIPLY:
+		baseCode = KeyKpMultiply
+	case vkc == xwindows.VK_ADD:
+		baseCode = KeyKpPlus
+	case vkc == xwindows.VK_SEPARATOR:
+		baseCode = KeyKpComma
+	case vkc == xwindows.VK_SUBTRACT:
+		baseCode = KeyKpMinus
+	case vkc == xwindows.VK_DECIMAL:
+		baseCode = KeyKpDecimal
+	case vkc == xwindows.VK_DIVIDE:
+		baseCode = KeyKpDivide
+	case vkc >= xwindows.VK_F1 && vkc <= xwindows.VK_F24:
+		baseCode = rune(vkc-xwindows.VK_F1) + KeyF1
+	case vkc == xwindows.VK_NUMLOCK:
+		baseCode = KeyNumLock
+	case vkc == xwindows.VK_SCROLL:
+		baseCode = KeyScrollLock
+	case vkc == xwindows.VK_LSHIFT:
+		baseCode = KeyLeftShift
+	case vkc == xwindows.VK_RSHIFT:
+		baseCode = KeyRightShift
+	case vkc == xwindows.VK_LCONTROL:
+		baseCode = KeyLeftCtrl
+	case vkc == xwindows.VK_RCONTROL:
+		baseCode = KeyRightCtrl
+	case vkc == xwindows.VK_LMENU:
+		baseCode = KeyLeftAlt
+	case vkc == xwindows.VK_RMENU:
+		baseCode = KeyRightAlt
+	case vkc == xwindows.VK_VOLUME_MUTE:
+		baseCode = KeyMute
+	case vkc == xwindows.VK_VOLUME_DOWN:
+		baseCode = KeyLowerVol
+	case vkc == xwindows.VK_VOLUME_UP:
+		baseCode = KeyRaiseVol
+	case vkc == xwindows.VK_MEDIA_NEXT_TRACK:
+		baseCode = KeyMediaNext
+	case vkc == xwindows.VK_MEDIA_PREV_TRACK:
+		baseCode = KeyMediaPrev
+	case vkc == xwindows.VK_MEDIA_STOP:
+		baseCode = KeyMediaStop
+	case vkc == xwindows.VK_MEDIA_PLAY_PAUSE:
+		baseCode = KeyMediaPlayPause
+	case vkc == xwindows.VK_OEM_1:
+		baseCode = ';'
+	case vkc == xwindows.VK_OEM_PLUS:
+		baseCode = '+'
+	case vkc == xwindows.VK_OEM_COMMA:
+		baseCode = ','
+	case vkc == xwindows.VK_OEM_MINUS:
+		baseCode = '-'
+	case vkc == xwindows.VK_OEM_PERIOD:
+		baseCode = '.'
+	case vkc == xwindows.VK_OEM_2:
+		baseCode = '/'
+	case vkc == xwindows.VK_OEM_3:
+		baseCode = '`'
+	case vkc == xwindows.VK_OEM_4:
+		baseCode = '['
+	case vkc == xwindows.VK_OEM_5:
+		baseCode = '\\'
+	case vkc == xwindows.VK_OEM_6:
+		baseCode = ']'
+	case vkc == xwindows.VK_OEM_7:
+		baseCode = '\''
+	}
+
+	if utf16.IsSurrogate(r) {
+		if state != nil {
+			state.utf16Buf[0] = r
+			state.utf16Half = true
+		}
+		return nil
+	}
+
+	// AltGr is left ctrl + right alt. On non-US keyboards, this is used to type
+	// special characters and produce printable events.
+	// XXX: Should this be a KeyMod?
+	altGr := cks&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED) == xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED
+
+	var text string
+	keyCode := baseCode
+	if !unicode.IsControl(r) {
+		rw := utf8.EncodeRune(utf8Buf[:], r)
+		keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
+		if unicode.IsPrint(keyCode) && (cks == 0 ||
+			cks == xwindows.SHIFT_PRESSED ||
+			cks == xwindows.CAPSLOCK_ON ||
+			altGr) {
+			// If the control key state is 0, shift is pressed, or caps lock
+			// then the key event is a printable event i.e. [text] is not empty.
+			text = string(keyCode)
+		}
+	}
+
+	key.Code = keyCode
+	key.Text = text
+	key.Mod = translateControlKeyState(cks)
+	key.BaseCode = baseCode
+	key = ensureKeyCase(key, cks)
+	if keyDown {
+		return KeyPressEvent(key)
+	}
+
+	return KeyReleaseEvent(key)
+}
+
+// ensureKeyCase ensures that the key's text is in the correct case based on the
+// control key state.
+func ensureKeyCase(key Key, cks uint32) Key {
+	if len(key.Text) == 0 {
+		return key
+	}
+
+	hasShift := cks&xwindows.SHIFT_PRESSED != 0
+	hasCaps := cks&xwindows.CAPSLOCK_ON != 0
+	if hasShift || hasCaps {
+		if unicode.IsLower(key.Code) {
+			key.ShiftedCode = unicode.ToUpper(key.Code)
+			key.Text = string(key.ShiftedCode)
+		}
+	} else {
+		if unicode.IsUpper(key.Code) {
+			key.ShiftedCode = unicode.ToLower(key.Code)
+			key.Text = string(key.ShiftedCode)
+		}
+	}
+
+	return key
+}
+
+// translateControlKeyState translates the control key state from the Windows
+// Console API into a Mod bitmask.
+func translateControlKeyState(cks uint32) (m KeyMod) {
+	if cks&xwindows.LEFT_CTRL_PRESSED != 0 || cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
+		m |= ModCtrl
+	}
+	if cks&xwindows.LEFT_ALT_PRESSED != 0 || cks&xwindows.RIGHT_ALT_PRESSED != 0 {
+		m |= ModAlt
+	}
+	if cks&xwindows.SHIFT_PRESSED != 0 {
+		m |= ModShift
+	}
+	if cks&xwindows.CAPSLOCK_ON != 0 {
+		m |= ModCapsLock
+	}
+	if cks&xwindows.NUMLOCK_ON != 0 {
+		m |= ModNumLock
+	}
+	if cks&xwindows.SCROLLLOCK_ON != 0 {
+		m |= ModScrollLock
+	}
+	return
+}
+
+//nolint:unused
+func keyEventString(vkc, sc uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) string {
+	var s strings.Builder
+	s.WriteString("vkc: ")
+	s.WriteString(fmt.Sprintf("%d, 0x%02x", vkc, vkc))
+	s.WriteString(", sc: ")
+	s.WriteString(fmt.Sprintf("%d, 0x%02x", sc, sc))
+	s.WriteString(", r: ")
+	s.WriteString(fmt.Sprintf("%q", r))
+	s.WriteString(", down: ")
+	s.WriteString(fmt.Sprintf("%v", keyDown))
+	s.WriteString(", cks: [")
+	if cks&xwindows.LEFT_ALT_PRESSED != 0 {
+		s.WriteString("left alt, ")
+	}
+	if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
+		s.WriteString("right alt, ")
+	}
+	if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
+		s.WriteString("left ctrl, ")
+	}
+	if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
+		s.WriteString("right ctrl, ")
+	}
+	if cks&xwindows.SHIFT_PRESSED != 0 {
+		s.WriteString("shift, ")
+	}
+	if cks&xwindows.CAPSLOCK_ON != 0 {
+		s.WriteString("caps lock, ")
+	}
+	if cks&xwindows.NUMLOCK_ON != 0 {
+		s.WriteString("num lock, ")
+	}
+	if cks&xwindows.SCROLLLOCK_ON != 0 {
+		s.WriteString("scroll lock, ")
+	}
+	if cks&xwindows.ENHANCED_KEY != 0 {
+		s.WriteString("enhanced key, ")
+	}
+	s.WriteString("], repeat count: ")
+	s.WriteString(fmt.Sprintf("%d", repeatCount))
+	return s.String()
+}

+ 271 - 0
packages/tui/input/driver_windows_test.go

@@ -0,0 +1,271 @@
+package input
+
+import (
+	"encoding/binary"
+	"image/color"
+	"reflect"
+	"testing"
+	"unicode/utf16"
+
+	"github.com/charmbracelet/x/ansi"
+	xwindows "github.com/charmbracelet/x/windows"
+	"golang.org/x/sys/windows"
+)
+
+func TestWindowsInputEvents(t *testing.T) {
+	cases := []struct {
+		name     string
+		events   []xwindows.InputRecord
+		expected []Event
+		sequence bool // indicates that the input events are ANSI sequence or utf16
+	}{
+		{
+			name: "single key event",
+			events: []xwindows.InputRecord{
+				encodeKeyEvent(xwindows.KeyEventRecord{
+					KeyDown:        true,
+					Char:           'a',
+					VirtualKeyCode: 'A',
+				}),
+			},
+			expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Text: "a"}},
+		},
+		{
+			name: "single key event with control key",
+			events: []xwindows.InputRecord{
+				encodeKeyEvent(xwindows.KeyEventRecord{
+					KeyDown:         true,
+					Char:            'a',
+					VirtualKeyCode:  'A',
+					ControlKeyState: xwindows.LEFT_CTRL_PRESSED,
+				}),
+			},
+			expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Mod: ModCtrl}},
+		},
+		{
+			name: "escape alt key event",
+			events: []xwindows.InputRecord{
+				encodeKeyEvent(xwindows.KeyEventRecord{
+					KeyDown:         true,
+					Char:            ansi.ESC,
+					VirtualKeyCode:  ansi.ESC,
+					ControlKeyState: xwindows.LEFT_ALT_PRESSED,
+				}),
+			},
+			expected: []Event{KeyPressEvent{Code: ansi.ESC, BaseCode: ansi.ESC, Mod: ModAlt}},
+		},
+		{
+			name: "single shifted key event",
+			events: []xwindows.InputRecord{
+				encodeKeyEvent(xwindows.KeyEventRecord{
+					KeyDown:         true,
+					Char:            'A',
+					VirtualKeyCode:  'A',
+					ControlKeyState: xwindows.SHIFT_PRESSED,
+				}),
+			},
+			expected: []Event{KeyPressEvent{Code: 'A', BaseCode: 'a', Text: "A", Mod: ModShift}},
+		},
+		{
+			name:   "utf16 rune",
+			events: encodeUtf16Rune('😊'), // smiley emoji '😊'
+			expected: []Event{
+				KeyPressEvent{Code: '😊', Text: "😊"},
+			},
+			sequence: true,
+		},
+		{
+			name:     "background color response",
+			events:   encodeSequence("\x1b]11;rgb:ff/ff/ff\x07"),
+			expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
+			sequence: true,
+		},
+		{
+			name:     "st terminated background color response",
+			events:   encodeSequence("\x1b]11;rgb:ffff/ffff/ffff\x1b\\"),
+			expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
+			sequence: true,
+		},
+		{
+			name: "simple mouse event",
+			events: []xwindows.InputRecord{
+				encodeMouseEvent(xwindows.MouseEventRecord{
+					MousePositon: windows.Coord{X: 10, Y: 20},
+					ButtonState:  xwindows.FROM_LEFT_1ST_BUTTON_PRESSED,
+					EventFlags:   0,
+				}),
+				encodeMouseEvent(xwindows.MouseEventRecord{
+					MousePositon: windows.Coord{X: 10, Y: 20},
+					EventFlags:   0,
+				}),
+			},
+			expected: []Event{
+				MouseClickEvent{Button: MouseLeft, X: 10, Y: 20},
+				MouseReleaseEvent{Button: MouseLeft, X: 10, Y: 20},
+			},
+		},
+		{
+			name: "focus event",
+			events: []xwindows.InputRecord{
+				encodeFocusEvent(xwindows.FocusEventRecord{
+					SetFocus: true,
+				}),
+				encodeFocusEvent(xwindows.FocusEventRecord{
+					SetFocus: false,
+				}),
+			},
+			expected: []Event{
+				FocusEvent{},
+				BlurEvent{},
+			},
+		},
+		{
+			name: "window size event",
+			events: []xwindows.InputRecord{
+				encodeWindowBufferSizeEvent(xwindows.WindowBufferSizeRecord{
+					Size: windows.Coord{X: 10, Y: 20},
+				}),
+			},
+			expected: []Event{
+				WindowSizeEvent{Width: 10, Height: 20},
+			},
+		},
+	}
+
+	// p is the parser to parse the input events
+	var p Parser
+
+	// keep track of the state of the driver to handle ANSI sequences and utf16
+	var state win32InputState
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.sequence {
+				var Event Event
+				for _, ev := range tc.events {
+					if ev.EventType != xwindows.KEY_EVENT {
+						t.Fatalf("expected key event, got %v", ev.EventType)
+					}
+
+					key := ev.KeyEvent()
+					Event = p.parseWin32InputKeyEvent(&state, key.VirtualKeyCode, key.VirtualScanCode, key.Char, key.KeyDown, key.ControlKeyState, key.RepeatCount)
+				}
+				if len(tc.expected) != 1 {
+					t.Fatalf("expected 1 event, got %d", len(tc.expected))
+				}
+				if !reflect.DeepEqual(Event, tc.expected[0]) {
+					t.Errorf("expected %v, got %v", tc.expected[0], Event)
+				}
+			} else {
+				if len(tc.events) != len(tc.expected) {
+					t.Fatalf("expected %d events, got %d", len(tc.expected), len(tc.events))
+				}
+				for j, ev := range tc.events {
+					Event := p.parseConInputEvent(ev, &state)
+					if !reflect.DeepEqual(Event, tc.expected[j]) {
+						t.Errorf("expected %#v, got %#v", tc.expected[j], Event)
+					}
+				}
+			}
+		})
+	}
+}
+
+func boolToUint32(b bool) uint32 {
+	if b {
+		return 1
+	}
+	return 0
+}
+
+func encodeMenuEvent(menu xwindows.MenuEventRecord) xwindows.InputRecord {
+	var bts [16]byte
+	binary.LittleEndian.PutUint32(bts[0:4], menu.CommandID)
+	return xwindows.InputRecord{
+		EventType: xwindows.MENU_EVENT,
+		Event:     bts,
+	}
+}
+
+func encodeWindowBufferSizeEvent(size xwindows.WindowBufferSizeRecord) xwindows.InputRecord {
+	var bts [16]byte
+	binary.LittleEndian.PutUint16(bts[0:2], uint16(size.Size.X))
+	binary.LittleEndian.PutUint16(bts[2:4], uint16(size.Size.Y))
+	return xwindows.InputRecord{
+		EventType: xwindows.WINDOW_BUFFER_SIZE_EVENT,
+		Event:     bts,
+	}
+}
+
+func encodeFocusEvent(focus xwindows.FocusEventRecord) xwindows.InputRecord {
+	var bts [16]byte
+	if focus.SetFocus {
+		bts[0] = 1
+	}
+	return xwindows.InputRecord{
+		EventType: xwindows.FOCUS_EVENT,
+		Event:     bts,
+	}
+}
+
+func encodeMouseEvent(mouse xwindows.MouseEventRecord) xwindows.InputRecord {
+	var bts [16]byte
+	binary.LittleEndian.PutUint16(bts[0:2], uint16(mouse.MousePositon.X))
+	binary.LittleEndian.PutUint16(bts[2:4], uint16(mouse.MousePositon.Y))
+	binary.LittleEndian.PutUint32(bts[4:8], mouse.ButtonState)
+	binary.LittleEndian.PutUint32(bts[8:12], mouse.ControlKeyState)
+	binary.LittleEndian.PutUint32(bts[12:16], mouse.EventFlags)
+	return xwindows.InputRecord{
+		EventType: xwindows.MOUSE_EVENT,
+		Event:     bts,
+	}
+}
+
+func encodeKeyEvent(key xwindows.KeyEventRecord) xwindows.InputRecord {
+	var bts [16]byte
+	binary.LittleEndian.PutUint32(bts[0:4], boolToUint32(key.KeyDown))
+	binary.LittleEndian.PutUint16(bts[4:6], key.RepeatCount)
+	binary.LittleEndian.PutUint16(bts[6:8], key.VirtualKeyCode)
+	binary.LittleEndian.PutUint16(bts[8:10], key.VirtualScanCode)
+	binary.LittleEndian.PutUint16(bts[10:12], uint16(key.Char))
+	binary.LittleEndian.PutUint32(bts[12:16], key.ControlKeyState)
+	return xwindows.InputRecord{
+		EventType: xwindows.KEY_EVENT,
+		Event:     bts,
+	}
+}
+
+// encodeSequence encodes a string of ANSI escape sequences into a slice of
+// Windows input key records.
+func encodeSequence(s string) (evs []xwindows.InputRecord) {
+	var state byte
+	for len(s) > 0 {
+		seq, _, n, newState := ansi.DecodeSequence(s, state, nil)
+		for i := 0; i < n; i++ {
+			evs = append(evs, encodeKeyEvent(xwindows.KeyEventRecord{
+				KeyDown: true,
+				Char:    rune(seq[i]),
+			}))
+		}
+		state = newState
+		s = s[n:]
+	}
+	return
+}
+
+func encodeUtf16Rune(r rune) []xwindows.InputRecord {
+	r1, r2 := utf16.EncodeRune(r)
+	return encodeUtf16Pair(r1, r2)
+}
+
+func encodeUtf16Pair(r1, r2 rune) []xwindows.InputRecord {
+	return []xwindows.InputRecord{
+		encodeKeyEvent(xwindows.KeyEventRecord{
+			KeyDown: true,
+			Char:    r1,
+		}),
+		encodeKeyEvent(xwindows.KeyEventRecord{
+			KeyDown: true,
+			Char:    r2,
+		}),
+	}
+}

+ 9 - 0
packages/tui/input/focus.go

@@ -0,0 +1,9 @@
+package input
+
+// FocusEvent represents a terminal focus event.
+// This occurs when the terminal gains focus.
+type FocusEvent struct{}
+
+// BlurEvent represents a terminal blur event.
+// This occurs when the terminal loses focus.
+type BlurEvent struct{}

+ 27 - 0
packages/tui/input/focus_test.go

@@ -0,0 +1,27 @@
+package input
+
+import (
+	"testing"
+)
+
+func TestFocus(t *testing.T) {
+	var p Parser
+	_, e := p.parseSequence([]byte("\x1b[I"))
+	switch e.(type) {
+	case FocusEvent:
+		// ok
+	default:
+		t.Error("invalid sequence")
+	}
+}
+
+func TestBlur(t *testing.T) {
+	var p Parser
+	_, e := p.parseSequence([]byte("\x1b[O"))
+	switch e.(type) {
+	case BlurEvent:
+		// ok
+	default:
+		t.Error("invalid sequence")
+	}
+}

+ 18 - 0
packages/tui/input/go.mod

@@ -0,0 +1,18 @@
+module github.com/charmbracelet/x/input
+
+go 1.23.0
+
+require (
+	github.com/charmbracelet/x/ansi v0.9.3
+	github.com/charmbracelet/x/windows v0.2.1
+	github.com/muesli/cancelreader v0.2.2
+	github.com/rivo/uniseg v0.4.7
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
+	golang.org/x/sys v0.33.0
+)
+
+require (
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
+	golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
+)

+ 19 - 0
packages/tui/input/go.sum

@@ -0,0 +1,19 @@
+github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
+github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
+github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
+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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

+ 45 - 0
packages/tui/input/input.go

@@ -0,0 +1,45 @@
+package input
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Event represents a terminal event.
+type Event any
+
+// UnknownEvent represents an unknown event.
+type UnknownEvent string
+
+// String returns a string representation of the unknown event.
+func (e UnknownEvent) String() string {
+	return fmt.Sprintf("%q", string(e))
+}
+
+// MultiEvent represents multiple messages event.
+type MultiEvent []Event
+
+// String returns a string representation of the multiple messages event.
+func (e MultiEvent) String() string {
+	var sb strings.Builder
+	for _, ev := range e {
+		sb.WriteString(fmt.Sprintf("%v\n", ev))
+	}
+	return sb.String()
+}
+
+// WindowSizeEvent is used to report the terminal size. Note that Windows does
+// not have support for reporting resizes via SIGWINCH signals and relies on
+// the Windows Console API to report window size changes.
+type WindowSizeEvent struct {
+	Width  int
+	Height int
+}
+
+// WindowOpEvent is a window operation (XTWINOPS) report event. This is used to
+// report various window operations such as reporting the window size or cell
+// size.
+type WindowOpEvent struct {
+	Op   int
+	Args []int
+}

+ 574 - 0
packages/tui/input/key.go

@@ -0,0 +1,574 @@
+package input
+
+import (
+	"fmt"
+	"strings"
+	"unicode"
+
+	"github.com/charmbracelet/x/ansi"
+)
+
+const (
+	// KeyExtended is a special key code used to signify that a key event
+	// contains multiple runes.
+	KeyExtended = unicode.MaxRune + 1
+)
+
+// Special key symbols.
+const (
+
+	// Special keys.
+
+	KeyUp rune = KeyExtended + iota + 1
+	KeyDown
+	KeyRight
+	KeyLeft
+	KeyBegin
+	KeyFind
+	KeyInsert
+	KeyDelete
+	KeySelect
+	KeyPgUp
+	KeyPgDown
+	KeyHome
+	KeyEnd
+
+	// Keypad keys.
+
+	KeyKpEnter
+	KeyKpEqual
+	KeyKpMultiply
+	KeyKpPlus
+	KeyKpComma
+	KeyKpMinus
+	KeyKpDecimal
+	KeyKpDivide
+	KeyKp0
+	KeyKp1
+	KeyKp2
+	KeyKp3
+	KeyKp4
+	KeyKp5
+	KeyKp6
+	KeyKp7
+	KeyKp8
+	KeyKp9
+
+	//nolint:godox
+	// 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
+	KeyF4
+	KeyF5
+	KeyF6
+	KeyF7
+	KeyF8
+	KeyF9
+	KeyF10
+	KeyF11
+	KeyF12
+	KeyF13
+	KeyF14
+	KeyF15
+	KeyF16
+	KeyF17
+	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
+
+	//nolint:godox
+	// 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
+
+	// Special names in C0.
+
+	KeyBackspace = rune(ansi.DEL)
+	KeyTab       = rune(ansi.HT)
+	KeyEnter     = rune(ansi.CR)
+	KeyReturn    = KeyEnter
+	KeyEscape    = rune(ansi.ESC)
+	KeyEsc       = KeyEscape
+
+	// Special names in G0.
+
+	KeySpace = rune(ansi.SP)
+)
+
+// KeyPressEvent represents a key press event.
+type KeyPressEvent Key
+
+// String implements [fmt.Stringer] and is quite useful for matching key
+// events. For details, on what this returns see [Key.String].
+func (k KeyPressEvent) String() string {
+	return Key(k).String()
+}
+
+// Keystroke returns the keystroke representation of the [Key]. 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 KeyPressEvent) Keystroke() string {
+	return Key(k).Keystroke()
+}
+
+// Key returns the underlying key event. This is a syntactic sugar for casting
+// the key event to a [Key].
+func (k KeyPressEvent) Key() Key {
+	return Key(k)
+}
+
+// KeyReleaseEvent represents a key release event.
+type KeyReleaseEvent Key
+
+// String implements [fmt.Stringer] and is quite useful for matching key
+// events. For details, on what this returns see [Key.String].
+func (k KeyReleaseEvent) String() string {
+	return Key(k).String()
+}
+
+// Keystroke returns the keystroke representation of the [Key]. 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 KeyReleaseEvent) Keystroke() string {
+	return Key(k).Keystroke()
+}
+
+// Key returns the underlying key event. This is a convenience method and
+// syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to
+// [Key].
+func (k KeyReleaseEvent) Key() Key {
+	return Key(k)
+}
+
+// KeyEvent represents a key event. This can be either a key press or a key
+// release event.
+type KeyEvent interface {
+	fmt.Stringer
+
+	// Key returns the underlying key event.
+	Key() Key
+}
+
+// Key represents a Key press or release event. It contains information about
+// the Key pressed, like the runes, the type of Key, and the modifiers pressed.
+// 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 ev := ev.(type) {
+//	case KeyPressEvent:
+//	    switch ev.String() {
+//	    case "enter":
+//	        fmt.Println("you pressed enter!")
+//	    case "a":
+//	        fmt.Println("you pressed a!")
+//	    }
+//	}
+//
+//	// Switch on the key type (more foolproof)
+//	switch ev := ev.(type) {
+//	case KeyEvent:
+//	    // catch both KeyPressEvent and KeyReleaseEvent
+//	    switch key := ev.Key(); key.Code {
+//	    case KeyEnter:
+//	        fmt.Println("you pressed enter!")
+//	    default:
+//	        switch key.Text {
+//	        case "a":
+//	            fmt.Println("you pressed a!")
+//	        }
+//	    }
+//	}
+//
+// Note that [Key.Text] will be empty for special keys like [KeyEnter],
+// [KeyTab], and for keys that don't represent printable characters like key
+// combos with modifier keys. In other words, [Key.Text] is populated only for
+// keys that represent printable characters shifted or unshifted (like 'a',
+// 'A', '1', '!', etc.).
+type Key struct {
+	// Text contains the actual characters received. This usually the same as
+	// [Key.Code]. When [Key.Text] is non-empty, it indicates that the key
+	// pressed represents printable character(s).
+	Text string
+
+	// Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on.
+	Mod KeyMod
+
+	// Code represents the key pressed. This is usually a special key like
+	// [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'.
+	Code rune
+
+	// ShiftedCode is the actual, shifted key pressed by the user. For example,
+	// if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will
+	// be 'A' and [Key.Code] will be 'a'.
+	//
+	// In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the
+	// unshifted key on the keyboard.
+	//
+	// This is only available with the Kitty Keyboard Protocol or the Windows
+	// Console API.
+	ShiftedCode rune
+
+	// BaseCode is the key pressed according to the standard PC-101 key layout.
+	// On international keyboards, this is the key that would be pressed if the
+	// keyboard was set to US PC-101 layout.
+	//
+	// For example, if the user presses 'q' on a French AZERTY keyboard,
+	// [Key.BaseCode] will be 'q'.
+	//
+	// This is only available with the Kitty Keyboard Protocol or the Windows
+	// Console API.
+	BaseCode rune
+
+	// 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
+}
+
+// String implements [fmt.Stringer] and is quite useful for matching key
+// events. It will return the textual representation of the [Key] if there is
+// one, otherwise, it will fallback to [Key.Keystroke].
+//
+// For example, you'll always get "?" and instead of "shift+/" on a US ANSI
+// keyboard.
+func (k Key) String() string {
+	if len(k.Text) > 0 && k.Text != " " {
+		return k.Text
+	}
+	return k.Keystroke()
+}
+
+// Keystroke returns the keystroke representation of the [Key]. 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) Keystroke() string {
+	var sb strings.Builder
+	if k.Mod.Contains(ModCtrl) && k.Code != KeyLeftCtrl && k.Code != KeyRightCtrl {
+		sb.WriteString("ctrl+")
+	}
+	if k.Mod.Contains(ModAlt) && k.Code != KeyLeftAlt && k.Code != KeyRightAlt {
+		sb.WriteString("alt+")
+	}
+	if k.Mod.Contains(ModShift) && k.Code != KeyLeftShift && k.Code != KeyRightShift {
+		sb.WriteString("shift+")
+	}
+	if k.Mod.Contains(ModMeta) && k.Code != KeyLeftMeta && k.Code != KeyRightMeta {
+		sb.WriteString("meta+")
+	}
+	if k.Mod.Contains(ModHyper) && k.Code != KeyLeftHyper && k.Code != KeyRightHyper {
+		sb.WriteString("hyper+")
+	}
+	if k.Mod.Contains(ModSuper) && k.Code != KeyLeftSuper && k.Code != KeyRightSuper {
+		sb.WriteString("super+")
+	}
+
+	if kt, ok := keyTypeString[k.Code]; ok {
+		sb.WriteString(kt)
+	} else {
+		code := k.Code
+		if k.BaseCode != 0 {
+			// If a [Key.BaseCode] is present, use it to represent a key using the standard
+			// PC-101 key layout.
+			code = k.BaseCode
+		}
+
+		switch code {
+		case KeySpace:
+			// Space is the only invisible printable character.
+			sb.WriteString("space")
+		case KeyExtended:
+			// Write the actual text of the key when the key contains multiple
+			// runes.
+			sb.WriteString(k.Text)
+		default:
+			sb.WriteRune(code)
+		}
+	}
+
+	return sb.String()
+}
+
+var keyTypeString = map[rune]string{
+	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",
+}

+ 880 - 0
packages/tui/input/key_test.go

@@ -0,0 +1,880 @@
+package input
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"image/color"
+	"io"
+	"math/rand"
+	"reflect"
+	"regexp"
+	"runtime"
+	"sort"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/ansi/kitty"
+)
+
+var sequences = buildKeysTable(FlagTerminfo, "dumb")
+
+func TestKeyString(t *testing.T) {
+	t.Run("alt+space", func(t *testing.T) {
+		k := KeyPressEvent{Code: KeySpace, 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) {
+		k := KeyPressEvent{Code: 'a', Text: "a"}
+		if got := k.String(); got != "a" {
+			t.Fatalf(`expected an "a", got %q`, got)
+		}
+	})
+
+	t.Run("invalid", func(t *testing.T) {
+		k := KeyPressEvent{Code: 99999}
+		if got := k.String(); got != "𘚟" {
+			t.Fatalf(`expected a "unknown", got %q`, got)
+		}
+	})
+
+	t.Run("space", func(t *testing.T) {
+		k := KeyPressEvent{Code: KeySpace, Text: " "}
+		if got := k.String(); got != "space" {
+			t.Fatalf(`expected a "space", got %q`, got)
+		}
+	})
+
+	t.Run("shift+space", func(t *testing.T) {
+		k := KeyPressEvent{Code: KeySpace, Mod: ModShift}
+		if got := k.String(); got != "shift+space" {
+			t.Fatalf(`expected a "shift+space", got %q`, got)
+		}
+	})
+
+	t.Run("?", func(t *testing.T) {
+		k := KeyPressEvent{Code: '/', Mod: ModShift, Text: "?"}
+		if got := k.String(); got != "?" {
+			t.Fatalf(`expected a "?", got %q`, got)
+		}
+	})
+}
+
+type seqTest struct {
+	seq    []byte
+	Events []Event
+}
+
+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 {
+		k := KeyPressEvent(key)
+		st := seqTest{seq: []byte(seq), Events: []Event{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.Events = []Event{k, CursorPositionEvent{Y: 0, X: int(key.Mod)}}
+		}
+		td = append(td, st)
+	}
+
+	// Additional special cases.
+	td = append(td,
+		// Unrecognized CSI sequence.
+		seqTest{
+			[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
+			[]Event{
+				UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
+			},
+		},
+		// A lone space character.
+		seqTest{
+			[]byte{' '},
+			[]Event{
+				KeyPressEvent{Code: KeySpace, Text: " "},
+			},
+		},
+		// An escape character with the alt modifier.
+		seqTest{
+			[]byte{'\x1b', ' '},
+			[]Event{
+				KeyPressEvent{Code: KeySpace, Mod: ModAlt},
+			},
+		},
+	)
+	return td
+}
+
+func TestParseSequence(t *testing.T) {
+	td := buildBaseSeqTests()
+	td = append(td,
+		// Background color.
+		seqTest{
+			[]byte("\x1b]11;rgb:1234/1234/1234\x07"),
+			[]Event{BackgroundColorEvent{
+				Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
+			}},
+		},
+		seqTest{
+			[]byte("\x1b]11;rgb:1234/1234/1234\x1b\\"),
+			[]Event{BackgroundColorEvent{
+				Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
+			}},
+		},
+		seqTest{
+			[]byte("\x1b]11;rgb:1234/1234/1234\x1b"), // Incomplete sequences are ignored.
+			[]Event{
+				UnknownEvent("\x1b]11;rgb:1234/1234/1234\x1b"),
+			},
+		},
+
+		// Kitty Graphics response.
+		seqTest{
+			[]byte("\x1b_Ga=t;OK\x1b\\"),
+			[]Event{KittyGraphicsEvent{
+				Options: kitty.Options{Action: kitty.Transmit},
+				Payload: []byte("OK"),
+			}},
+		},
+		seqTest{
+			[]byte("\x1b_Gi=99,I=13;OK\x1b\\"),
+			[]Event{KittyGraphicsEvent{
+				Options: kitty.Options{ID: 99, Number: 13},
+				Payload: []byte("OK"),
+			}},
+		},
+		seqTest{
+			[]byte("\x1b_Gi=1337,q=1;EINVAL:your face\x1b\\"),
+			[]Event{KittyGraphicsEvent{
+				Options: kitty.Options{ID: 1337, Quite: 1},
+				Payload: []byte("EINVAL:your face"),
+			}},
+		},
+
+		// Xterm modifyOtherKeys CSI 27 ; <modifier> ; <code> ~
+		seqTest{
+			[]byte("\x1b[27;3;20320~"),
+			[]Event{KeyPressEvent{Code: '你', Mod: ModAlt}},
+		},
+		seqTest{
+			[]byte("\x1b[27;3;65~"),
+			[]Event{KeyPressEvent{Code: 'A', Mod: ModAlt}},
+		},
+		seqTest{
+			[]byte("\x1b[27;3;8~"),
+			[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
+		},
+		seqTest{
+			[]byte("\x1b[27;3;27~"),
+			[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
+		},
+		seqTest{
+			[]byte("\x1b[27;3;127~"),
+			[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
+		},
+
+		// Xterm report window text area size.
+		seqTest{
+			[]byte("\x1b[4;24;80t"),
+			[]Event{
+				WindowOpEvent{Op: 4, Args: []int{24, 80}},
+			},
+		},
+
+		// Kitty keyboard / CSI u (fixterms)
+		seqTest{
+			[]byte("\x1b[1B"),
+			[]Event{KeyPressEvent{Code: KeyDown}},
+		},
+		seqTest{
+			[]byte("\x1b[1;B"),
+			[]Event{KeyPressEvent{Code: KeyDown}},
+		},
+		seqTest{
+			[]byte("\x1b[1;4B"),
+			[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
+		},
+		seqTest{
+			[]byte("\x1b[1;4:1B"),
+			[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
+		},
+		seqTest{
+			[]byte("\x1b[1;4:2B"),
+			[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown, IsRepeat: true}},
+		},
+		seqTest{
+			[]byte("\x1b[1;4:3B"),
+			[]Event{KeyReleaseEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
+		},
+		seqTest{
+			[]byte("\x1b[8~"),
+			[]Event{KeyPressEvent{Code: KeyEnd}},
+		},
+		seqTest{
+			[]byte("\x1b[8;~"),
+			[]Event{KeyPressEvent{Code: KeyEnd}},
+		},
+		seqTest{
+			[]byte("\x1b[8;10~"),
+			[]Event{KeyPressEvent{Mod: ModShift | ModMeta, Code: KeyEnd}},
+		},
+		seqTest{
+			[]byte("\x1b[27;4u"),
+			[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyEscape}},
+		},
+		seqTest{
+			[]byte("\x1b[127;4u"),
+			[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyBackspace}},
+		},
+		seqTest{
+			[]byte("\x1b[57358;4u"),
+			[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyCapsLock}},
+		},
+		seqTest{
+			[]byte("\x1b[9;2u"),
+			[]Event{KeyPressEvent{Mod: ModShift, Code: KeyTab}},
+		},
+		seqTest{
+			[]byte("\x1b[195;u"),
+			[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
+		},
+		seqTest{
+			[]byte("\x1b[20320;2u"),
+			[]Event{KeyPressEvent{Text: "你", Mod: ModShift, Code: '你'}},
+		},
+		seqTest{
+			[]byte("\x1b[195;:1u"),
+			[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
+		},
+		seqTest{
+			[]byte("\x1b[195;2:3u"),
+			[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
+		},
+		seqTest{
+			[]byte("\x1b[195;2:2u"),
+			[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", IsRepeat: true, Mod: ModShift}},
+		},
+		seqTest{
+			[]byte("\x1b[195;2:1u"),
+			[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
+		},
+		seqTest{
+			[]byte("\x1b[195;2:3u"),
+			[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
+		},
+		seqTest{
+			[]byte("\x1b[97;2;65u"),
+			[]Event{KeyPressEvent{Code: 'a', Text: "A", Mod: ModShift}},
+		},
+		seqTest{
+			[]byte("\x1b[97;;229u"),
+			[]Event{KeyPressEvent{Code: 'a', Text: "å"}},
+		},
+
+		// focus/blur
+		seqTest{
+			[]byte{'\x1b', '[', 'I'},
+			[]Event{
+				FocusEvent{},
+			},
+		},
+		seqTest{
+			[]byte{'\x1b', '[', 'O'},
+			[]Event{
+				BlurEvent{},
+			},
+		},
+		// Mouse event.
+		seqTest{
+			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
+			[]Event{
+				MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
+			},
+		},
+		// SGR Mouse event.
+		seqTest{
+			[]byte("\x1b[<0;33;17M"),
+			[]Event{
+				MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
+			},
+		},
+		// Runes.
+		seqTest{
+			[]byte{'a'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Text: "a"},
+			},
+		},
+		seqTest{
+			[]byte{'\x1b', 'a'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Mod: ModAlt},
+			},
+		},
+		seqTest{
+			[]byte{'a', 'a', 'a'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Text: "a"},
+				KeyPressEvent{Code: 'a', Text: "a"},
+				KeyPressEvent{Code: 'a', Text: "a"},
+			},
+		},
+		// Multi-byte rune.
+		seqTest{
+			[]byte("☃"),
+			[]Event{
+				KeyPressEvent{Code: '☃', Text: "☃"},
+			},
+		},
+		seqTest{
+			[]byte("\x1b☃"),
+			[]Event{
+				KeyPressEvent{Code: '☃', Mod: ModAlt},
+			},
+		},
+		// Standalone control characters.
+		seqTest{
+			[]byte{'\x1b'},
+			[]Event{
+				KeyPressEvent{Code: KeyEscape},
+			},
+		},
+		seqTest{
+			[]byte{ansi.SOH},
+			[]Event{
+				KeyPressEvent{Code: 'a', Mod: ModCtrl},
+			},
+		},
+		seqTest{
+			[]byte{'\x1b', ansi.SOH},
+			[]Event{
+				KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
+			},
+		},
+		seqTest{
+			[]byte{ansi.NUL},
+			[]Event{
+				KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
+			},
+		},
+		seqTest{
+			[]byte{'\x1b', ansi.NUL},
+			[]Event{
+				KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt},
+			},
+		},
+		// C1 control characters.
+		seqTest{
+			[]byte{'\x80'},
+			[]Event{
+				KeyPressEvent{Code: rune(0x80 - '@'), Mod: ModCtrl | ModAlt},
+			},
+		},
+	)
+
+	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.
+		td = append(td, seqTest{
+			[]byte{'\xfe'},
+			[]Event{
+				UnknownEvent(rune(0xfe)),
+			},
+		})
+	}
+
+	var p Parser
+	for _, tc := range td {
+		t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
+			var events []Event
+			buf := tc.seq
+			for len(buf) > 0 {
+				width, Event := p.parseSequence(buf)
+				switch Event := Event.(type) {
+				case MultiEvent:
+					events = append(events, Event...)
+				default:
+					events = append(events, Event)
+				}
+				buf = buf[width:]
+			}
+			if !reflect.DeepEqual(tc.Events, events) {
+				t.Errorf("\nexpected event for %q:\n    %#v\ngot:\n    %#v", tc.seq, tc.Events, events)
+			}
+		})
+	}
+}
+
+func TestReadLongInput(t *testing.T) {
+	expect := make([]Event, 1000)
+	for i := range 1000 {
+		expect[i] = KeyPressEvent{Code: 'a', Text: "a"}
+	}
+	input := strings.Repeat("a", 1000)
+	drv, err := NewReader(strings.NewReader(input), "dumb", 0)
+	if err != nil {
+		t.Fatalf("unexpected input driver error: %v", err)
+	}
+
+	var Events []Event
+	for {
+		events, err := drv.ReadEvents()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			t.Fatalf("unexpected input error: %v", err)
+		}
+		Events = append(Events, events...)
+	}
+
+	if !reflect.DeepEqual(expect, Events) {
+		t.Errorf("unexpected messages, expected:\n    %+v\ngot:\n    %+v", expect, Events)
+	}
+}
+
+func TestReadInput(t *testing.T) {
+	type test struct {
+		keyname string
+		in      []byte
+		out     []Event
+	}
+	testData := []test{
+		{
+			"a",
+			[]byte{'a'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Text: "a"},
+			},
+		},
+		{
+			"space",
+			[]byte{' '},
+			[]Event{
+				KeyPressEvent{Code: KeySpace, Text: " "},
+			},
+		},
+		{
+			"a alt+a",
+			[]byte{'a', '\x1b', 'a'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Text: "a"},
+				KeyPressEvent{Code: 'a', Mod: ModAlt},
+			},
+		},
+		{
+			"a alt+a a",
+			[]byte{'a', '\x1b', 'a', 'a'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Text: "a"},
+				KeyPressEvent{Code: 'a', Mod: ModAlt},
+				KeyPressEvent{Code: 'a', Text: "a"},
+			},
+		},
+		{
+			"ctrl+a",
+			[]byte{byte(ansi.SOH)},
+			[]Event{
+				KeyPressEvent{Code: 'a', Mod: ModCtrl},
+			},
+		},
+		{
+			"ctrl+a ctrl+b",
+			[]byte{byte(ansi.SOH), byte(ansi.STX)},
+			[]Event{
+				KeyPressEvent{Code: 'a', Mod: ModCtrl},
+				KeyPressEvent{Code: 'b', Mod: ModCtrl},
+			},
+		},
+		{
+			"alt+a",
+			[]byte{byte(0x1b), 'a'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Mod: ModAlt},
+			},
+		},
+		{
+			"a b c d",
+			[]byte{'a', 'b', 'c', 'd'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Text: "a"},
+				KeyPressEvent{Code: 'b', Text: "b"},
+				KeyPressEvent{Code: 'c', Text: "c"},
+				KeyPressEvent{Code: 'd', Text: "d"},
+			},
+		},
+		{
+			"up",
+			[]byte("\x1b[A"),
+			[]Event{
+				KeyPressEvent{Code: KeyUp},
+			},
+		},
+		{
+			"wheel up",
+			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
+			[]Event{
+				MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
+			},
+		},
+		{
+			"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),
+			},
+			[]Event{
+				MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
+				MouseReleaseEvent{X: 64, Y: 32, Button: MouseNone},
+			},
+		},
+		{
+			"shift+tab",
+			[]byte{'\x1b', '[', 'Z'},
+			[]Event{
+				KeyPressEvent{Code: KeyTab, Mod: ModShift},
+			},
+		},
+		{
+			"enter",
+			[]byte{'\r'},
+			[]Event{KeyPressEvent{Code: KeyEnter}},
+		},
+		{
+			"alt+enter",
+			[]byte{'\x1b', '\r'},
+			[]Event{
+				KeyPressEvent{Code: KeyEnter, Mod: ModAlt},
+			},
+		},
+		{
+			"insert",
+			[]byte{'\x1b', '[', '2', '~'},
+			[]Event{
+				KeyPressEvent{Code: KeyInsert},
+			},
+		},
+		{
+			"ctrl+alt+a",
+			[]byte{'\x1b', byte(ansi.SOH)},
+			[]Event{
+				KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
+			},
+		},
+		{
+			"CSI?----X?",
+			[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
+			[]Event{UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
+		},
+		// Powershell sequences.
+		{
+			"up",
+			[]byte{'\x1b', 'O', 'A'},
+			[]Event{KeyPressEvent{Code: KeyUp}},
+		},
+		{
+			"down",
+			[]byte{'\x1b', 'O', 'B'},
+			[]Event{KeyPressEvent{Code: KeyDown}},
+		},
+		{
+			"right",
+			[]byte{'\x1b', 'O', 'C'},
+			[]Event{KeyPressEvent{Code: KeyRight}},
+		},
+		{
+			"left",
+			[]byte{'\x1b', 'O', 'D'},
+			[]Event{KeyPressEvent{Code: KeyLeft}},
+		},
+		{
+			"alt+enter",
+			[]byte{'\x1b', '\x0d'},
+			[]Event{KeyPressEvent{Code: KeyEnter, Mod: ModAlt}},
+		},
+		{
+			"alt+backspace",
+			[]byte{'\x1b', '\x7f'},
+			[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
+		},
+		{
+			"ctrl+space",
+			[]byte{'\x00'},
+			[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl}},
+		},
+		{
+			"ctrl+alt+space",
+			[]byte{'\x1b', '\x00'},
+			[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt}},
+		},
+		{
+			"esc",
+			[]byte{'\x1b'},
+			[]Event{KeyPressEvent{Code: KeyEscape}},
+		},
+		{
+			"alt+esc",
+			[]byte{'\x1b', '\x1b'},
+			[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
+		},
+		{
+			"a b o",
+			[]byte{
+				'\x1b', '[', '2', '0', '0', '~',
+				'a', ' ', 'b',
+				'\x1b', '[', '2', '0', '1', '~',
+				'o',
+			},
+			[]Event{
+				PasteStartEvent{},
+				PasteEvent("a b"),
+				PasteEndEvent{},
+				KeyPressEvent{Code: 'o', Text: "o"},
+			},
+		},
+		{
+			"a\x03\nb",
+			[]byte{
+				'\x1b', '[', '2', '0', '0', '~',
+				'a', '\x03', '\n', 'b',
+				'\x1b', '[', '2', '0', '1', '~',
+			},
+			[]Event{
+				PasteStartEvent{},
+				PasteEvent("a\x03\nb"),
+				PasteEndEvent{},
+			},
+		},
+		{
+			"?0xfe?",
+			[]byte{'\xfe'},
+			[]Event{
+				UnknownEvent(rune(0xfe)),
+			},
+		},
+		{
+			"a ?0xfe?   b",
+			[]byte{'a', '\xfe', ' ', 'b'},
+			[]Event{
+				KeyPressEvent{Code: 'a', Text: "a"},
+				UnknownEvent(rune(0xfe)),
+				KeyPressEvent{Code: KeySpace, Text: " "},
+				KeyPressEvent{Code: 'b', Text: "b"},
+			},
+		},
+	}
+
+	for i, td := range testData {
+		t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) {
+			Events := testReadInputs(t, bytes.NewReader(td.in))
+			var buf strings.Builder
+			for i, Event := range Events {
+				if i > 0 {
+					buf.WriteByte(' ')
+				}
+				if s, ok := Event.(fmt.Stringer); ok {
+					buf.WriteString(s.String())
+				} else {
+					fmt.Fprintf(&buf, "%#v:%T", Event, Event)
+				}
+			}
+
+			if len(Events) != len(td.out) {
+				t.Fatalf("unexpected message list length: got %d, expected %d\n  got: %#v\n  expected: %#v\n", len(Events), len(td.out), Events, td.out)
+			}
+
+			if !reflect.DeepEqual(td.out, Events) {
+				t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, Events)
+			}
+		})
+	}
+}
+
+func testReadInputs(t *testing.T, input io.Reader) []Event {
+	// We'll check that the input reader finishes at the end
+	// without error.
+	var wg sync.WaitGroup
+	var inputErr error
+	ctx, cancel := context.WithCancel(context.Background())
+	defer func() {
+		cancel()
+		wg.Wait()
+		if inputErr != nil && !errors.Is(inputErr, io.EOF) {
+			t.Fatalf("unexpected input error: %v", inputErr)
+		}
+	}()
+
+	dr, err := NewReader(input, "dumb", 0)
+	if err != nil {
+		t.Fatalf("unexpected input driver error: %v", err)
+	}
+
+	// The messages we're consuming.
+	EventsC := make(chan Event)
+
+	// Start the reader in the background.
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		var events []Event
+		events, inputErr = dr.ReadEvents()
+	out:
+		for _, ev := range events {
+			select {
+			case EventsC <- ev:
+			case <-ctx.Done():
+				break out
+			}
+		}
+		EventsC <- nil
+	}()
+
+	var Events []Event
+loop:
+	for {
+		select {
+		case Event := <-EventsC:
+			if Event == nil {
+				// end of input marker for the test.
+				break loop
+			}
+			Events = append(Events, Event)
+		case <-time.After(2 * time.Second):
+			t.Errorf("timeout waiting for input event")
+			break loop
+		}
+	}
+	return Events
+}
+
+// randTest defines the test input and expected output for a sequence
+// of interleaved control sequences and control characters.
+type randTest struct {
+	data    []byte
+	lengths []int
+	names   []string
+}
+
+// seed is the random seed to randomize the input. This helps check
+// that all the sequences get ultimately exercised.
+var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)")
+
+// genRandomData generates a randomized test, with a random seed unless
+// the seed flag was set.
+func genRandomData(logfn func(int64), length int) randTest {
+	// We'll use a random source. However, we give the user the option
+	// to override it to a specific value for reproduceability.
+	s := *seed
+	if s == 0 {
+		s = time.Now().UnixNano()
+	}
+	// Inform the user so they know what to reuse to get the same data.
+	logfn(s)
+	return genRandomDataWithSeed(s, length)
+}
+
+// genRandomDataWithSeed generates a randomized test with a fixed seed.
+func genRandomDataWithSeed(s int64, length int) randTest {
+	src := rand.NewSource(s)
+	r := rand.New(src)
+
+	// allseqs contains all the sequences, in sorted order. We sort
+	// to make the test deterministic (when the seed is also fixed).
+	type seqpair struct {
+		seq  string
+		name string
+	}
+	var allseqs []seqpair
+	for seq, key := range sequences {
+		allseqs = append(allseqs, seqpair{seq, key.String()})
+	}
+	sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq })
+
+	// res contains the computed test.
+	var res randTest
+
+	for len(res.data) < length {
+		alt := r.Intn(2)
+		prefix := ""
+		esclen := 0
+		if alt == 1 {
+			prefix = "alt+"
+			esclen = 1
+		}
+		kind := r.Intn(3)
+		switch kind {
+		case 0:
+			// A control character.
+			if alt == 1 {
+				res.data = append(res.data, '\x1b')
+			}
+			res.data = append(res.data, 1)
+			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.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") {
+				esclen = 0
+				prefix = ""
+				alt = 0
+			}
+			if alt == 1 {
+				res.data = append(res.data, '\x1b')
+			}
+			res.data = append(res.data, s.seq...)
+			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
+}
+
+func FuzzParseSequence(f *testing.F) {
+	var p Parser
+	for seq := range sequences {
+		f.Add(seq)
+	}
+	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, _ := p.parseSequence([]byte(seq))
+		if n == 0 && seq != "" {
+			t.Errorf("expected a non-zero width for %q", seq)
+		}
+	})
+}
+
+// BenchmarkDetectSequenceMap benchmarks the map-based sequence
+// detector.
+func BenchmarkDetectSequenceMap(b *testing.B) {
+	var p Parser
+	td := genRandomDataWithSeed(123, 10000)
+	for i := 0; i < b.N; i++ {
+		for j, w := 0, 0; j < len(td.data); j += w {
+			w, _ = p.parseSequence(td.data[j:])
+		}
+	}
+}

+ 353 - 0
packages/tui/input/kitty.go

@@ -0,0 +1,353 @@
+package input
+
+import (
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/ansi/kitty"
+)
+
+// KittyGraphicsEvent represents a Kitty Graphics response event.
+//
+// See https://sw.kovidgoyal.net/kitty/graphics-protocol/
+type KittyGraphicsEvent struct {
+	Options kitty.Options
+	Payload []byte
+}
+
+// KittyEnhancementsEvent represents a Kitty enhancements event.
+type KittyEnhancementsEvent int
+
+// Kitty keyboard enhancement constants.
+// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
+const (
+	KittyDisambiguateEscapeCodes KittyEnhancementsEvent = 1 << iota
+	KittyReportEventTypes
+	KittyReportAlternateKeys
+	KittyReportAllKeysAsEscapeCodes
+	KittyReportAssociatedText
+)
+
+// Contains reports whether m contains the given enhancements.
+func (e KittyEnhancementsEvent) Contains(enhancements KittyEnhancementsEvent) bool {
+	return e&enhancements == enhancements
+}
+
+// Kitty Clipboard Control Sequences.
+var kittyKeyMap = map[int]Key{
+	ansi.BS:  {Code: KeyBackspace},
+	ansi.HT:  {Code: KeyTab},
+	ansi.CR:  {Code: KeyEnter},
+	ansi.ESC: {Code: KeyEscape},
+	ansi.DEL: {Code: KeyBackspace},
+
+	57344: {Code: KeyEscape},
+	57345: {Code: KeyEnter},
+	57346: {Code: KeyTab},
+	57347: {Code: KeyBackspace},
+	57348: {Code: KeyInsert},
+	57349: {Code: KeyDelete},
+	57350: {Code: KeyLeft},
+	57351: {Code: KeyRight},
+	57352: {Code: KeyUp},
+	57353: {Code: KeyDown},
+	57354: {Code: KeyPgUp},
+	57355: {Code: KeyPgDown},
+	57356: {Code: KeyHome},
+	57357: {Code: KeyEnd},
+	57358: {Code: KeyCapsLock},
+	57359: {Code: KeyScrollLock},
+	57360: {Code: KeyNumLock},
+	57361: {Code: KeyPrintScreen},
+	57362: {Code: KeyPause},
+	57363: {Code: KeyMenu},
+	57364: {Code: KeyF1},
+	57365: {Code: KeyF2},
+	57366: {Code: KeyF3},
+	57367: {Code: KeyF4},
+	57368: {Code: KeyF5},
+	57369: {Code: KeyF6},
+	57370: {Code: KeyF7},
+	57371: {Code: KeyF8},
+	57372: {Code: KeyF9},
+	57373: {Code: KeyF10},
+	57374: {Code: KeyF11},
+	57375: {Code: KeyF12},
+	57376: {Code: KeyF13},
+	57377: {Code: KeyF14},
+	57378: {Code: KeyF15},
+	57379: {Code: KeyF16},
+	57380: {Code: KeyF17},
+	57381: {Code: KeyF18},
+	57382: {Code: KeyF19},
+	57383: {Code: KeyF20},
+	57384: {Code: KeyF21},
+	57385: {Code: KeyF22},
+	57386: {Code: KeyF23},
+	57387: {Code: KeyF24},
+	57388: {Code: KeyF25},
+	57389: {Code: KeyF26},
+	57390: {Code: KeyF27},
+	57391: {Code: KeyF28},
+	57392: {Code: KeyF29},
+	57393: {Code: KeyF30},
+	57394: {Code: KeyF31},
+	57395: {Code: KeyF32},
+	57396: {Code: KeyF33},
+	57397: {Code: KeyF34},
+	57398: {Code: KeyF35},
+	57399: {Code: KeyKp0},
+	57400: {Code: KeyKp1},
+	57401: {Code: KeyKp2},
+	57402: {Code: KeyKp3},
+	57403: {Code: KeyKp4},
+	57404: {Code: KeyKp5},
+	57405: {Code: KeyKp6},
+	57406: {Code: KeyKp7},
+	57407: {Code: KeyKp8},
+	57408: {Code: KeyKp9},
+	57409: {Code: KeyKpDecimal},
+	57410: {Code: KeyKpDivide},
+	57411: {Code: KeyKpMultiply},
+	57412: {Code: KeyKpMinus},
+	57413: {Code: KeyKpPlus},
+	57414: {Code: KeyKpEnter},
+	57415: {Code: KeyKpEqual},
+	57416: {Code: KeyKpSep},
+	57417: {Code: KeyKpLeft},
+	57418: {Code: KeyKpRight},
+	57419: {Code: KeyKpUp},
+	57420: {Code: KeyKpDown},
+	57421: {Code: KeyKpPgUp},
+	57422: {Code: KeyKpPgDown},
+	57423: {Code: KeyKpHome},
+	57424: {Code: KeyKpEnd},
+	57425: {Code: KeyKpInsert},
+	57426: {Code: KeyKpDelete},
+	57427: {Code: KeyKpBegin},
+	57428: {Code: KeyMediaPlay},
+	57429: {Code: KeyMediaPause},
+	57430: {Code: KeyMediaPlayPause},
+	57431: {Code: KeyMediaReverse},
+	57432: {Code: KeyMediaStop},
+	57433: {Code: KeyMediaFastForward},
+	57434: {Code: KeyMediaRewind},
+	57435: {Code: KeyMediaNext},
+	57436: {Code: KeyMediaPrev},
+	57437: {Code: KeyMediaRecord},
+	57438: {Code: KeyLowerVol},
+	57439: {Code: KeyRaiseVol},
+	57440: {Code: KeyMute},
+	57441: {Code: KeyLeftShift},
+	57442: {Code: KeyLeftCtrl},
+	57443: {Code: KeyLeftAlt},
+	57444: {Code: KeyLeftSuper},
+	57445: {Code: KeyLeftHyper},
+	57446: {Code: KeyLeftMeta},
+	57447: {Code: KeyRightShift},
+	57448: {Code: KeyRightCtrl},
+	57449: {Code: KeyRightAlt},
+	57450: {Code: KeyRightSuper},
+	57451: {Code: KeyRightHyper},
+	57452: {Code: KeyRightMeta},
+	57453: {Code: KeyIsoLevel3Shift},
+	57454: {Code: KeyIsoLevel5Shift},
+}
+
+func init() {
+	// These are some faulty C0 mappings some terminals such as WezTerm have
+	// and doesn't follow the specs.
+	kittyKeyMap[ansi.NUL] = Key{Code: KeySpace, Mod: ModCtrl}
+	for i := ansi.SOH; i <= ansi.SUB; i++ {
+		if _, ok := kittyKeyMap[i]; !ok {
+			kittyKeyMap[i] = Key{Code: rune(i + 0x60), Mod: ModCtrl}
+		}
+	}
+	for i := ansi.FS; i <= ansi.US; i++ {
+		if _, ok := kittyKeyMap[i]; !ok {
+			kittyKeyMap[i] = Key{Code: rune(i + 0x40), Mod: ModCtrl}
+		}
+	}
+}
+
+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(params ansi.Params) (Event Event) {
+	var isRelease bool
+	var key Key
+
+	// The index of parameters separated by semicolons ';'. Sub parameters are
+	// separated by colons ':'.
+	var paramIdx int
+	var sudIdx int // The sub parameter index
+	for _, p := range params {
+		// Kitty Keyboard Protocol has 3 optional components.
+		switch paramIdx {
+		case 0:
+			switch sudIdx {
+			case 0:
+				var foundKey bool
+				code := p.Param(1) // CSI u has a default value of 1
+				key, foundKey = kittyKeyMap[code]
+				if !foundKey {
+					r := rune(code)
+					if !utf8.ValidRune(r) {
+						r = utf8.RuneError
+					}
+
+					key.Code = r
+				}
+
+			case 2:
+				// shifted key + base key
+				if b := rune(p.Param(1)); 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.BaseCode = b
+				}
+				fallthrough
+
+			case 1:
+				// shifted key
+				if s := rune(p.Param(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.ShiftedCode = s
+				}
+			}
+		case 1:
+			switch sudIdx {
+			case 0:
+				mod := p.Param(1)
+				if mod > 1 {
+					key.Mod = fromKittyMod(mod - 1)
+					if key.Mod > ModShift {
+						// XXX: We need to clear the text if we have a modifier key
+						// other than a [ModShift] key.
+						key.Text = ""
+					}
+				}
+
+			case 1:
+				switch p.Param(1) {
+				case 2:
+					key.IsRepeat = true
+				case 3:
+					isRelease = true
+				}
+			case 2:
+			}
+		case 2:
+			if code := p.Param(0); code != 0 {
+				key.Text += string(rune(code))
+			}
+		}
+
+		sudIdx++
+		if !p.HasMore() {
+			paramIdx++
+			sudIdx = 0
+		}
+	}
+
+	//nolint:nestif
+	if len(key.Text) == 0 && unicode.IsPrint(key.Code) &&
+		(key.Mod <= ModShift || key.Mod == ModCapsLock || key.Mod == ModShift|ModCapsLock) {
+		if key.Mod == 0 {
+			key.Text = string(key.Code)
+		} else {
+			desiredCase := unicode.ToLower
+			if key.Mod.Contains(ModShift) || key.Mod.Contains(ModCapsLock) {
+				desiredCase = unicode.ToUpper
+			}
+			if key.ShiftedCode != 0 {
+				key.Text = string(key.ShiftedCode)
+			} else {
+				key.Text = string(desiredCase(key.Code))
+			}
+		}
+	}
+
+	if isRelease {
+		return KeyReleaseEvent(key)
+	}
+
+	return KeyPressEvent(key)
+}
+
+// parseKittyKeyboardExt parses a Kitty Keyboard Protocol sequence extensions
+// for non CSI u sequences. This includes things like CSI A, SS3 A and others,
+// and CSI ~.
+func parseKittyKeyboardExt(params ansi.Params, k KeyPressEvent) Event {
+	// Handle Kitty keyboard protocol
+	if len(params) > 2 && // We have at least 3 parameters
+		params[0].Param(1) == 1 && // The first parameter is 1 (defaults to 1)
+		params[1].HasMore() { // The second parameter is a subparameter (separated by a ":")
+		switch params[2].Param(1) { // The third parameter is the event type (defaults to 1)
+		case 2:
+			k.IsRepeat = true
+		case 3:
+			return KeyReleaseEvent(k)
+		}
+	}
+	return k
+}

+ 37 - 0
packages/tui/input/mod.go

@@ -0,0 +1,37 @@
+package input
+
+// KeyMod represents modifier keys.
+type KeyMod int
+
+// 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
+}

+ 14 - 0
packages/tui/input/mode.go

@@ -0,0 +1,14 @@
+package input
+
+import "github.com/charmbracelet/x/ansi"
+
+// ModeReportEvent is a message that represents a mode report event (DECRPM).
+//
+// See: https://vt100.net/docs/vt510-rm/DECRPM.html
+type ModeReportEvent struct {
+	// Mode is the mode number.
+	Mode ansi.Mode
+
+	// Value is the mode value.
+	Value ansi.ModeSetting
+}

+ 292 - 0
packages/tui/input/mouse.go

@@ -0,0 +1,292 @@
+package input
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/x/ansi"
+)
+
+// MouseButton represents the button that was pressed during a mouse message.
+type MouseButton = ansi.MouseButton
+
+// 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.
+const (
+	MouseNone       = ansi.MouseNone
+	MouseLeft       = ansi.MouseLeft
+	MouseMiddle     = ansi.MouseMiddle
+	MouseRight      = ansi.MouseRight
+	MouseWheelUp    = ansi.MouseWheelUp
+	MouseWheelDown  = ansi.MouseWheelDown
+	MouseWheelLeft  = ansi.MouseWheelLeft
+	MouseWheelRight = ansi.MouseWheelRight
+	MouseBackward   = ansi.MouseBackward
+	MouseForward    = ansi.MouseForward
+	MouseButton10   = ansi.MouseButton10
+	MouseButton11   = ansi.MouseButton11
+)
+
+// MouseEvent represents a mouse message. This is a generic mouse message that
+// can represent any kind of mouse event.
+type MouseEvent interface {
+	fmt.Stringer
+
+	// Mouse returns the underlying mouse event.
+	Mouse() Mouse
+}
+
+// Mouse represents a Mouse message. Use [MouseEvent] to represent all mouse
+// messages.
+//
+// The X and Y coordinates are zero-based, with (0,0) being the upper left
+// corner of the terminal.
+//
+//	// Catch all mouse events
+//	switch Event := Event.(type) {
+//	case MouseEvent:
+//	    m := Event.Mouse()
+//	    fmt.Println("Mouse event:", m.X, m.Y, m)
+//	}
+//
+//	// Only catch mouse click events
+//	switch Event := Event.(type) {
+//	case MouseClickEvent:
+//	    fmt.Println("Mouse click event:", Event.X, Event.Y, Event)
+//	}
+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 := m.Button.String()
+	if str == "" {
+		s += "unknown"
+	} else if str != "none" { // motion events don't have a button
+		s += str
+	}
+
+	return s
+}
+
+// MouseClickEvent represents a mouse button click event.
+type MouseClickEvent Mouse
+
+// String returns a string representation of the mouse click event.
+func (e MouseClickEvent) String() string {
+	return Mouse(e).String()
+}
+
+// Mouse returns the underlying mouse event. This is a convenience method and
+// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
+// event to [Mouse].
+func (e MouseClickEvent) Mouse() Mouse {
+	return Mouse(e)
+}
+
+// MouseReleaseEvent represents a mouse button release event.
+type MouseReleaseEvent Mouse
+
+// String returns a string representation of the mouse release event.
+func (e MouseReleaseEvent) String() string {
+	return Mouse(e).String()
+}
+
+// Mouse returns the underlying mouse event. This is a convenience method and
+// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
+// event to [Mouse].
+func (e MouseReleaseEvent) Mouse() Mouse {
+	return Mouse(e)
+}
+
+// MouseWheelEvent represents a mouse wheel message event.
+type MouseWheelEvent Mouse
+
+// String returns a string representation of the mouse wheel event.
+func (e MouseWheelEvent) String() string {
+	return Mouse(e).String()
+}
+
+// Mouse returns the underlying mouse event. This is a convenience method and
+// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
+// event to [Mouse].
+func (e MouseWheelEvent) Mouse() Mouse {
+	return Mouse(e)
+}
+
+// MouseMotionEvent represents a mouse motion event.
+type MouseMotionEvent Mouse
+
+// String returns a string representation of the mouse motion event.
+func (e MouseMotionEvent) String() string {
+	m := Mouse(e)
+	if m.Button != 0 {
+		return m.String() + "+motion"
+	}
+	return m.String() + "motion"
+}
+
+// Mouse returns the underlying mouse event. This is a convenience method and
+// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
+// event to [Mouse].
+func (e MouseMotionEvent) Mouse() Mouse {
+	return Mouse(e)
+}
+
+// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
+// look like:
+//
+//	ESC [ < Cb ; Cx ; Cy (M or m)
+//
+// where:
+//
+//	Cb is the encoded button code
+//	Cx is the x-coordinate of the mouse
+//	Cy is the y-coordinate of the mouse
+//	M is for button press, m is for button release
+//
+// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
+func parseSGRMouseEvent(cmd ansi.Cmd, params ansi.Params) Event {
+	x, _, ok := params.Param(1, 1)
+	if !ok {
+		x = 1
+	}
+	y, _, ok := params.Param(2, 1)
+	if !ok {
+		y = 1
+	}
+	release := cmd.Final() == 'm'
+	b, _, _ := params.Param(0, 0)
+	mod, btn, _, isMotion := parseMouseButton(b)
+
+	// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
+	x--
+	y--
+
+	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 isWheel(m.Button) {
+		return MouseWheelEvent(m)
+	} else if !isMotion && release {
+		return MouseReleaseEvent(m)
+	} else if isMotion {
+		return MouseMotionEvent(m)
+	}
+	return MouseClickEvent(m)
+}
+
+const x10MouseByteOffset = 32
+
+// Parse X10-encoded mouse events; the simplest kind. The last release of X10
+// was December 1986, by the way. The original X10 mouse protocol limits the Cx
+// and Cy coordinates to 223 (=255-032).
+//
+// X10 mouse events look like:
+//
+//	ESC [M Cb Cx Cy
+//
+// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
+func parseX10MouseEvent(buf []byte) Event {
+	v := buf[3:6]
+	b := int(v[0])
+	if b >= x10MouseByteOffset {
+		// XXX: b < 32 should be impossible, but we're being defensive.
+		b -= x10MouseByteOffset
+	}
+
+	mod, btn, isRelease, isMotion := parseMouseButton(b)
+
+	// (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 MouseWheelEvent(m)
+	} else if isMotion {
+		return MouseMotionEvent(m)
+	} else if isRelease {
+		return MouseReleaseEvent(m)
+	}
+	return MouseClickEvent(m)
+}
+
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
+func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) {
+	// mouse bit shifts
+	const (
+		bitShift  = 0b0000_0100
+		bitAlt    = 0b0000_1000
+		bitCtrl   = 0b0001_0000
+		bitMotion = 0b0010_0000
+		bitWheel  = 0b0100_0000
+		bitAdd    = 0b1000_0000 // additional buttons 8-11
+
+		bitsMask = 0b0000_0011
+	)
+
+	// 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 {
+		btn = MouseLeft + MouseButton(b&bitsMask)
+		// X10 reports a button release as 0b0000_0011 (3)
+		if b&bitsMask == bitsMask {
+			btn = MouseNone
+			isRelease = true
+		}
+	}
+
+	// Motion bit doesn't get reported for wheel events.
+	if b&bitMotion != 0 && !isWheel(btn) {
+		isMotion = true
+	}
+
+	return //nolint:nakedret
+}
+
+// isWheel returns true if the mouse event is a wheel event.
+func isWheel(btn MouseButton) bool {
+	return btn >= MouseWheelUp && btn <= MouseWheelRight
+}

+ 481 - 0
packages/tui/input/mouse_test.go

@@ -0,0 +1,481 @@
+package input
+
+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    Event
+		expected string
+	}{
+		{
+			name:     "unknown",
+			event:    MouseClickEvent{Button: MouseButton(0xff)},
+			expected: "unknown",
+		},
+		{
+			name:     "left",
+			event:    MouseClickEvent{Button: MouseLeft},
+			expected: "left",
+		},
+		{
+			name:     "right",
+			event:    MouseClickEvent{Button: MouseRight},
+			expected: "right",
+		},
+		{
+			name:     "middle",
+			event:    MouseClickEvent{Button: MouseMiddle},
+			expected: "middle",
+		},
+		{
+			name:     "release",
+			event:    MouseReleaseEvent{Button: MouseNone},
+			expected: "",
+		},
+		{
+			name:     "wheelup",
+			event:    MouseWheelEvent{Button: MouseWheelUp},
+			expected: "wheelup",
+		},
+		{
+			name:     "wheeldown",
+			event:    MouseWheelEvent{Button: MouseWheelDown},
+			expected: "wheeldown",
+		},
+		{
+			name:     "wheelleft",
+			event:    MouseWheelEvent{Button: MouseWheelLeft},
+			expected: "wheelleft",
+		},
+		{
+			name:     "wheelright",
+			event:    MouseWheelEvent{Button: MouseWheelRight},
+			expected: "wheelright",
+		},
+		{
+			name:     "motion",
+			event:    MouseMotionEvent{Button: MouseNone},
+			expected: "motion",
+		},
+		{
+			name:     "shift+left",
+			event:    MouseReleaseEvent{Button: MouseLeft, Mod: ModShift},
+			expected: "shift+left",
+		},
+		{
+			name: "shift+left", event: MouseClickEvent{Button: MouseLeft, Mod: ModShift},
+			expected: "shift+left",
+		},
+		{
+			name:     "ctrl+shift+left",
+			event:    MouseClickEvent{Button: MouseLeft, Mod: ModCtrl | ModShift},
+			expected: "ctrl+shift+left",
+		},
+		{
+			name:     "alt+left",
+			event:    MouseClickEvent{Button: MouseLeft, Mod: ModAlt},
+			expected: "alt+left",
+		},
+		{
+			name:     "ctrl+left",
+			event:    MouseClickEvent{Button: MouseLeft, Mod: ModCtrl},
+			expected: "ctrl+left",
+		},
+		{
+			name:     "ctrl+alt+left",
+			event:    MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl},
+			expected: "ctrl+alt+left",
+		},
+		{
+			name:     "ctrl+alt+shift+left",
+			event:    MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift},
+			expected: "ctrl+alt+shift+left",
+		},
+		{
+			name:     "ignore coordinates",
+			event:    MouseClickEvent{X: 100, Y: 200, Button: MouseLeft},
+			expected: "left",
+		},
+		{
+			name:     "broken type",
+			event:    MouseClickEvent{Button: MouseButton(120)},
+			expected: "unknown",
+		},
+	}
+
+	for i := range tt {
+		tc := tt[i]
+
+		t.Run(tc.name, func(t *testing.T) {
+			actual := fmt.Sprint(tc.event)
+
+			if tc.expected != actual {
+				t.Fatalf("expected %q but got %q",
+					tc.expected,
+					actual,
+				)
+			}
+		})
+	}
+}
+
+func TestParseX10MouseDownEvent(t *testing.T) {
+	encode := func(b byte, x, y int) []byte {
+		return []byte{
+			'\x1b',
+			'[',
+			'M',
+			byte(32) + b,
+			byte(x + 32 + 1),
+			byte(y + 32 + 1),
+		}
+	}
+
+	tt := []struct {
+		name     string
+		buf      []byte
+		expected Event
+	}{
+		// Position.
+		{
+			name:     "zero position",
+			buf:      encode(0b0000_0000, 0, 0),
+			expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
+		},
+		{
+			name:     "max position",
+			buf:      encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
+			expected: MouseClickEvent{X: 222, Y: 222, Button: MouseLeft},
+		},
+		// Simple.
+		{
+			name:     "left",
+			buf:      encode(0b0000_0000, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "left in motion",
+			buf:      encode(0b0010_0000, 32, 16),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "middle",
+			buf:      encode(0b0000_0001, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "middle in motion",
+			buf:      encode(0b0010_0001, 32, 16),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "right",
+			buf:      encode(0b0000_0010, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
+		},
+		{
+			name:     "right in motion",
+			buf:      encode(0b0010_0010, 32, 16),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseRight},
+		},
+		{
+			name:     "motion",
+			buf:      encode(0b0010_0011, 32, 16),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
+		},
+		{
+			name:     "wheel up",
+			buf:      encode(0b0100_0000, 32, 16),
+			expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
+		},
+		{
+			name:     "wheel down",
+			buf:      encode(0b0100_0001, 32, 16),
+			expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
+		},
+		{
+			name:     "wheel left",
+			buf:      encode(0b0100_0010, 32, 16),
+			expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
+		},
+		{
+			name:     "wheel right",
+			buf:      encode(0b0100_0011, 32, 16),
+			expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
+		},
+		{
+			name:     "release",
+			buf:      encode(0b0000_0011, 32, 16),
+			expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseNone},
+		},
+		{
+			name:     "backward",
+			buf:      encode(0b1000_0000, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
+		},
+		{
+			name:     "forward",
+			buf:      encode(0b1000_0001, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
+		},
+		{
+			name:     "button 10",
+			buf:      encode(0b1000_0010, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton10},
+		},
+		{
+			name:     "button 11",
+			buf:      encode(0b1000_0011, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton11},
+		},
+		// Combinations.
+		{
+			name:     "alt+right",
+			buf:      encode(0b0000_1010, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+right",
+			buf:      encode(0b0001_0010, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "left in motion",
+			buf:      encode(0b0010_0000, 32, 16),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "alt+right in motion",
+			buf:      encode(0b0010_1010, 32, 16),
+			expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+right in motion",
+			buf:      encode(0b0011_0010, 32, 16),
+			expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+alt+right",
+			buf:      encode(0b0001_1010, 32, 16),
+			expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+wheel up",
+			buf:      encode(0b0101_0000, 32, 16),
+			expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp},
+		},
+		{
+			name:     "alt+wheel down",
+			buf:      encode(0b0100_1001, 32, 16),
+			expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
+		},
+		{
+			name:     "ctrl+alt+wheel down",
+			buf:      encode(0b0101_1001, 32, 16),
+			expected: MouseWheelEvent{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: MouseMotionEvent{X: -6, Y: -33, Button: MouseLeft},
+		},
+	}
+
+	for i := range tt {
+		tc := tt[i]
+
+		t.Run(tc.name, func(t *testing.T) {
+			actual := parseX10MouseEvent(tc.buf)
+
+			if tc.expected != actual {
+				t.Fatalf("expected %#v but got %#v",
+					tc.expected,
+					actual,
+				)
+			}
+		})
+	}
+}
+
+func TestParseSGRMouseEvent(t *testing.T) {
+	type csiSequence struct {
+		params []ansi.Param
+		cmd    ansi.Cmd
+	}
+	encode := func(b, x, y int, r bool) *csiSequence {
+		re := 'M'
+		if r {
+			re = 'm'
+		}
+		return &csiSequence{
+			params: []ansi.Param{
+				ansi.Param(b),
+				ansi.Param(x + 1),
+				ansi.Param(y + 1),
+			},
+			cmd: ansi.Cmd(re) | ('<' << parser.PrefixShift),
+		}
+	}
+
+	tt := []struct {
+		name     string
+		buf      *csiSequence
+		expected Event
+	}{
+		// Position.
+		{
+			name:     "zero position",
+			buf:      encode(0, 0, 0, false),
+			expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
+		},
+		{
+			name:     "225 position",
+			buf:      encode(0, 225, 225, false),
+			expected: MouseClickEvent{X: 225, Y: 225, Button: MouseLeft},
+		},
+		// Simple.
+		{
+			name:     "left",
+			buf:      encode(0, 32, 16, false),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "left in motion",
+			buf:      encode(32, 32, 16, false),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "left",
+			buf:      encode(0, 32, 16, true),
+			expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseLeft},
+		},
+		{
+			name:     "middle",
+			buf:      encode(1, 32, 16, false),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "middle in motion",
+			buf:      encode(33, 32, 16, false),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "middle",
+			buf:      encode(1, 32, 16, true),
+			expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseMiddle},
+		},
+		{
+			name:     "right",
+			buf:      encode(2, 32, 16, false),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
+		},
+		{
+			name:     "right",
+			buf:      encode(2, 32, 16, true),
+			expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseRight},
+		},
+		{
+			name:     "motion",
+			buf:      encode(35, 32, 16, false),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
+		},
+		{
+			name:     "wheel up",
+			buf:      encode(64, 32, 16, false),
+			expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
+		},
+		{
+			name:     "wheel down",
+			buf:      encode(65, 32, 16, false),
+			expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
+		},
+		{
+			name:     "wheel left",
+			buf:      encode(66, 32, 16, false),
+			expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
+		},
+		{
+			name:     "wheel right",
+			buf:      encode(67, 32, 16, false),
+			expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
+		},
+		{
+			name:     "backward",
+			buf:      encode(128, 32, 16, false),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
+		},
+		{
+			name:     "backward in motion",
+			buf:      encode(160, 32, 16, false),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseBackward},
+		},
+		{
+			name:     "forward",
+			buf:      encode(129, 32, 16, false),
+			expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
+		},
+		{
+			name:     "forward in motion",
+			buf:      encode(161, 32, 16, false),
+			expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseForward},
+		},
+		// Combinations.
+		{
+			name:     "alt+right",
+			buf:      encode(10, 32, 16, false),
+			expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+right",
+			buf:      encode(18, 32, 16, false),
+			expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "ctrl+alt+right",
+			buf:      encode(26, 32, 16, false),
+			expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
+		},
+		{
+			name:     "alt+wheel",
+			buf:      encode(73, 32, 16, false),
+			expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
+		},
+		{
+			name:     "ctrl+wheel",
+			buf:      encode(81, 32, 16, false),
+			expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown},
+		},
+		{
+			name:     "ctrl+alt+wheel",
+			buf:      encode(89, 32, 16, false),
+			expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
+		},
+		{
+			name:     "ctrl+alt+shift+wheel",
+			buf:      encode(93, 32, 16, false),
+			expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown},
+		},
+	}
+
+	for i := range tt {
+		tc := tt[i]
+
+		t.Run(tc.name, func(t *testing.T) {
+			actual := parseSGRMouseEvent(tc.buf.cmd, tc.buf.params)
+			if tc.expected != actual {
+				t.Fatalf("expected %#v but got %#v",
+					tc.expected,
+					actual,
+				)
+			}
+		})
+	}
+}

+ 1029 - 0
packages/tui/input/parse.go

@@ -0,0 +1,1029 @@
+package input
+
+import (
+	"bytes"
+	"encoding/base64"
+	"slices"
+	"strings"
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/ansi/parser"
+	"github.com/rivo/uniseg"
+)
+
+// Flags to control the behavior of the parser.
+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
+
+	// When this flag is set, the driver will enable mouse mode on Windows.
+	// This is only useful on Windows and has no effect on other platforms.
+	FlagMouseMode
+)
+
+// Parser is a parser for input escape sequences.
+type Parser struct {
+	flags int
+}
+
+// NewParser returns a new input parser. This is a low-level parser that parses
+// escape sequences into human-readable events.
+// This differs from [ansi.Parser] and [ansi.DecodeSequence] in which it
+// recognizes incorrect sequences that some terminals may send.
+//
+// For instance, the X10 mouse protocol sends a `CSI M` sequence followed by 3
+// bytes. If the parser doesn't recognize the 3 bytes, they might be echoed to
+// the terminal output causing a mess.
+//
+// Another example is how URxvt sends invalid sequences for modified keys using
+// invalid CSI final characters like '$'.
+//
+// Use flags to control the behavior of ambiguous key sequences.
+func NewParser(flags int) *Parser {
+	return &Parser{flags: flags}
+}
+
+// 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 (p *Parser) parseSequence(buf []byte) (n int, Event Event) {
+	if len(buf) == 0 {
+		return 0, nil
+	}
+
+	switch b := buf[0]; b {
+	case ansi.ESC:
+		if len(buf) == 1 {
+			// Escape key
+			return 1, KeyPressEvent{Code: KeyEscape}
+		}
+
+		switch bPrime := buf[1]; bPrime {
+		case 'O': // Esc-prefixed SS3
+			return p.parseSs3(buf)
+		case 'P': // Esc-prefixed DCS
+			return p.parseDcs(buf)
+		case '[': // Esc-prefixed CSI
+			return p.parseCsi(buf)
+		case ']': // Esc-prefixed OSC
+			return p.parseOsc(buf)
+		case '_': // Esc-prefixed APC
+			return p.parseApc(buf)
+		case '^': // Esc-prefixed PM
+			return p.parseStTerminated(ansi.PM, '^', nil)(buf)
+		case 'X': // Esc-prefixed SOS
+			return p.parseStTerminated(ansi.SOS, 'X', nil)(buf)
+		default:
+			n, e := p.parseSequence(buf[1:])
+			if k, ok := e.(KeyPressEvent); ok {
+				k.Text = ""
+				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, KeyPressEvent{Code: KeyEscape}
+		}
+	case ansi.SS3:
+		return p.parseSs3(buf)
+	case ansi.DCS:
+		return p.parseDcs(buf)
+	case ansi.CSI:
+		return p.parseCsi(buf)
+	case ansi.OSC:
+		return p.parseOsc(buf)
+	case ansi.APC:
+		return p.parseApc(buf)
+	case ansi.PM:
+		return p.parseStTerminated(ansi.PM, '^', nil)(buf)
+	case ansi.SOS:
+		return p.parseStTerminated(ansi.SOS, 'X', nil)(buf)
+	default:
+		if b <= ansi.US || b == ansi.DEL || b == ansi.SP {
+			return 1, p.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>
+			code := rune(b) - 0x40
+			return 1, KeyPressEvent{Code: code, Mod: ModCtrl | ModAlt}
+		}
+		return p.parseUtf8(buf)
+	}
+}
+
+func (p *Parser) parseCsi(b []byte) (int, Event) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+[ key
+		return 2, KeyPressEvent{Text: string(rune(b[1])), Mod: ModAlt}
+	}
+
+	var cmd ansi.Cmd
+	var params [parser.MaxParamsSize]ansi.Param
+	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] <= '?' {
+		cmd |= ansi.Cmd(b[i]) << parser.PrefixShift
+	}
+
+	// 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] += ansi.Param(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
+	cmd |= ansi.Cmd(intermed) << parser.IntermedShift
+
+	// Scan final byte in the range 0x40-0x7E
+	if i >= len(b) {
+		// Incomplete sequence
+		return 0, nil
+	}
+	if 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 := p.parseCsi(append(b[:i-1], '~'))
+			if k, ok := ev.(KeyPressEvent); ok {
+				k.Mod |= ModShift
+				return n, k
+			}
+		}
+		return i, UnknownEvent(b[:i-1])
+	}
+
+	// Add the final byte
+	cmd |= ansi.Cmd(b[i])
+	i++
+
+	pa := ansi.Params(params[:paramsLen])
+	switch cmd {
+	case 'y' | '?'<<parser.PrefixShift | '$'<<parser.IntermedShift:
+		// Report Mode (DECRPM)
+		mode, _, ok := pa.Param(0, -1)
+		if !ok || mode == -1 {
+			break
+		}
+		value, _, ok := pa.Param(1, -1)
+		if !ok || value == -1 {
+			break
+		}
+		return i, ModeReportEvent{Mode: ansi.DECMode(mode), Value: ansi.ModeSetting(value)}
+	case 'c' | '?'<<parser.PrefixShift:
+		// Primary Device Attributes
+		return i, parsePrimaryDevAttrs(pa)
+	case 'u' | '?'<<parser.PrefixShift:
+		// Kitty keyboard flags
+		flags, _, ok := pa.Param(0, -1)
+		if !ok || flags == -1 {
+			break
+		}
+		return i, KittyEnhancementsEvent(flags)
+	case 'R' | '?'<<parser.PrefixShift:
+		// This report may return a third parameter representing the page
+		// number, but we don't really need it.
+		row, _, ok := pa.Param(0, 1)
+		if !ok {
+			break
+		}
+		col, _, ok := pa.Param(1, 1)
+		if !ok {
+			break
+		}
+		return i, CursorPositionEvent{Y: row - 1, X: col - 1}
+	case 'm' | '<'<<parser.PrefixShift, 'M' | '<'<<parser.PrefixShift:
+		// Handle SGR mouse
+		if paramsLen == 3 {
+			return i, parseSGRMouseEvent(cmd, pa)
+		}
+	case 'm' | '>'<<parser.PrefixShift:
+		// XTerm modifyOtherKeys
+		mok, _, ok := pa.Param(0, 0)
+		if !ok || mok != 4 {
+			break
+		}
+		val, _, ok := pa.Param(1, -1)
+		if !ok || val == -1 {
+			break
+		}
+		return i, ModifyOtherKeysEvent(val) //nolint:gosec
+	case 'I':
+		return i, FocusEvent{}
+	case 'O':
+		return i, BlurEvent{}
+	case 'R':
+		// Cursor position report OR modified F3
+		row, _, rok := pa.Param(0, 1)
+		col, _, cok := pa.Param(1, 1)
+		if paramsLen == 2 && rok && cok {
+			m := CursorPositionEvent{Y: row - 1, X: col - 1}
+			if row == 1 && col-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, MultiEvent{KeyPressEvent{Code: KeyF3, Mod: KeyMod(col - 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 KeyPressEvent
+		switch cmd {
+		case 'a', 'b', 'c', 'd':
+			k = KeyPressEvent{Code: KeyUp + rune(cmd-'a'), Mod: ModShift}
+		case 'A', 'B', 'C', 'D':
+			k = KeyPressEvent{Code: KeyUp + rune(cmd-'A')}
+		case 'E':
+			k = KeyPressEvent{Code: KeyBegin}
+		case 'F':
+			k = KeyPressEvent{Code: KeyEnd}
+		case 'H':
+			k = KeyPressEvent{Code: KeyHome}
+		case 'P', 'Q', 'R', 'S':
+			k = KeyPressEvent{Code: KeyF1 + rune(cmd-'P')}
+		case 'Z':
+			k = KeyPressEvent{Code: KeyTab, Mod: ModShift}
+		}
+		id, _, _ := pa.Param(0, 1)
+		if id == 0 {
+			id = 1
+		}
+		mod, _, _ := pa.Param(1, 1)
+		if mod == 0 {
+			mod = 1
+		}
+		if paramsLen > 1 && id == 1 && mod != -1 {
+			// CSI 1 ; <modifiers> A
+			k.Mod |= KeyMod(mod - 1)
+		}
+		// Don't forget to handle Kitty keyboard protocol
+		return i, parseKittyKeyboardExt(pa, k)
+	case 'M':
+		// Handle X10 mouse
+		if i+2 >= len(b) {
+			// Incomplete sequence
+			return 0, nil
+		}
+		// PERFORMANCE: Do not use append here, as it will allocate a new slice
+		// for every mouse event. Instead, pass a sub-slice of the original
+		// buffer.
+		return i + 3, parseX10MouseEvent(b[i-1 : i+3])
+	case 'y' | '$'<<parser.IntermedShift:
+		// Report Mode (DECRPM)
+		mode, _, ok := pa.Param(0, -1)
+		if !ok || mode == -1 {
+			break
+		}
+		val, _, ok := pa.Param(1, -1)
+		if !ok || val == -1 {
+			break
+		}
+		return i, ModeReportEvent{Mode: ansi.ANSIMode(mode), Value: ansi.ModeSetting(val)}
+	case 'u':
+		// Kitty keyboard protocol & CSI u (fixterms)
+		if paramsLen == 0 {
+			return i, UnknownEvent(b[:i])
+		}
+		return i, parseKittyKeyboard(pa)
+	case '_':
+		// Win32 Input Mode
+		if paramsLen != 6 {
+			return i, UnknownEvent(b[:i])
+		}
+
+		vrc, _, _ := pa.Param(5, 0)
+		rc := uint16(vrc) //nolint:gosec
+		if rc == 0 {
+			rc = 1
+		}
+
+		vk, _, _ := pa.Param(0, 0)
+		sc, _, _ := pa.Param(1, 0)
+		uc, _, _ := pa.Param(2, 0)
+		kd, _, _ := pa.Param(3, 0)
+		cs, _, _ := pa.Param(4, 0)
+		event := p.parseWin32InputKeyEvent(
+			nil,
+			uint16(vk), //nolint:gosec // Vk wVirtualKeyCode
+			uint16(sc), //nolint:gosec // Sc wVirtualScanCode
+			rune(uc),   // Uc UnicodeChar
+			kd == 1,    // Kd bKeyDown
+			uint32(cs), //nolint:gosec // Cs dwControlKeyState
+			rc,         // Rc wRepeatCount
+		)
+
+		if event == nil {
+			return i, UnknownEvent(b[:])
+		}
+
+		return i, event
+	case '@', '^', '~':
+		if paramsLen == 0 {
+			return i, UnknownEvent(b[:i])
+		}
+
+		param, _, _ := pa.Param(0, 0)
+		switch cmd {
+		case '~':
+			switch param {
+			case 27:
+				// XTerm modifyOtherKeys 2
+				if paramsLen != 3 {
+					return i, UnknownEvent(b[:i])
+				}
+				return i, parseXTermModifyOtherKeys(pa)
+			case 200:
+				// bracketed-paste start
+				return i, PasteStartEvent{}
+			case 201:
+				// bracketed-paste end
+				return i, PasteEndEvent{}
+			}
+		}
+
+		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 KeyPressEvent
+			switch param {
+			case 1:
+				if p.flags&FlagFind != 0 {
+					k = KeyPressEvent{Code: KeyFind}
+				} else {
+					k = KeyPressEvent{Code: KeyHome}
+				}
+			case 2:
+				k = KeyPressEvent{Code: KeyInsert}
+			case 3:
+				k = KeyPressEvent{Code: KeyDelete}
+			case 4:
+				if p.flags&FlagSelect != 0 {
+					k = KeyPressEvent{Code: KeySelect}
+				} else {
+					k = KeyPressEvent{Code: KeyEnd}
+				}
+			case 5:
+				k = KeyPressEvent{Code: KeyPgUp}
+			case 6:
+				k = KeyPressEvent{Code: KeyPgDown}
+			case 7:
+				k = KeyPressEvent{Code: KeyHome}
+			case 8:
+				k = KeyPressEvent{Code: KeyEnd}
+			case 11, 12, 13, 14, 15:
+				k = KeyPressEvent{Code: KeyF1 + rune(param-11)}
+			case 17, 18, 19, 20, 21:
+				k = KeyPressEvent{Code: KeyF6 + rune(param-17)}
+			case 23, 24, 25, 26:
+				k = KeyPressEvent{Code: KeyF11 + rune(param-23)}
+			case 28, 29:
+				k = KeyPressEvent{Code: KeyF15 + rune(param-28)}
+			case 31, 32, 33, 34:
+				k = KeyPressEvent{Code: KeyF17 + rune(param-31)}
+			}
+
+			// modifiers
+			mod, _, _ := pa.Param(1, -1)
+			if paramsLen > 1 && mod != -1 {
+				k.Mod |= KeyMod(mod - 1)
+			}
+
+			// Handle URxvt weird keys
+			switch cmd {
+			case '~':
+				// Don't forget to handle Kitty keyboard protocol
+				return i, parseKittyKeyboardExt(pa, k)
+			case '^':
+				k.Mod |= ModCtrl
+			case '@':
+				k.Mod |= ModCtrl | ModShift
+			}
+
+			return i, k
+		}
+
+	case 't':
+		param, _, ok := pa.Param(0, 0)
+		if !ok {
+			break
+		}
+
+		var winop WindowOpEvent
+		winop.Op = param
+		for j := 1; j < paramsLen; j++ {
+			val, _, ok := pa.Param(j, 0)
+			if ok {
+				winop.Args = append(winop.Args, val)
+			}
+		}
+
+		return i, winop
+	}
+	return i, UnknownEvent(b[:i])
+}
+
+// parseSs3 parses a SS3 sequence.
+// See https://vt100.net/docs/vt220-rm/chapter4.html#S4.4.4.2
+func (p *Parser) parseSs3(b []byte) (int, Event) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+O key
+		return 2, KeyPressEvent{Code: 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) {
+		// Incomplete sequence
+		return 0, nil
+	}
+	if b[i] < 0x21 || b[i] > 0x7E {
+		return i, UnknownEvent(b[:i])
+	}
+
+	// GL character(s)
+	gl := b[i]
+	i++
+
+	var k KeyPressEvent
+	switch gl {
+	case 'a', 'b', 'c', 'd':
+		k = KeyPressEvent{Code: KeyUp + rune(gl-'a'), Mod: ModCtrl}
+	case 'A', 'B', 'C', 'D':
+		k = KeyPressEvent{Code: KeyUp + rune(gl-'A')}
+	case 'E':
+		k = KeyPressEvent{Code: KeyBegin}
+	case 'F':
+		k = KeyPressEvent{Code: KeyEnd}
+	case 'H':
+		k = KeyPressEvent{Code: KeyHome}
+	case 'P', 'Q', 'R', 'S':
+		k = KeyPressEvent{Code: KeyF1 + rune(gl-'P')}
+	case 'M':
+		k = KeyPressEvent{Code: KeyKpEnter}
+	case 'X':
+		k = KeyPressEvent{Code: KeyKpEqual}
+	case 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y':
+		k = KeyPressEvent{Code: KeyKpMultiply + rune(gl-'j')}
+	default:
+		return i, UnknownEvent(b[:i])
+	}
+
+	// Handle weird SS3 <modifier> Func
+	if mod > 0 {
+		k.Mod |= KeyMod(mod - 1)
+	}
+
+	return i, k
+}
+
+func (p *Parser) parseOsc(b []byte) (int, Event) {
+	defaultKey := func() KeyPressEvent {
+		return KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+	}
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+] key
+		return 2, defaultKey()
+	}
+
+	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 slices.Contains([]byte{ansi.BEL, ansi.ESC, ansi.ST, ansi.CAN, ansi.SUB}, b[i]) {
+			break
+		}
+	}
+
+	if i >= len(b) {
+		// Incomplete sequence
+		return 0, nil
+	}
+
+	end = i // end of the sequence data
+	i++
+
+	// Check 7-bit ST (string terminator) character
+	switch b[i-1] {
+	case ansi.CAN, ansi.SUB:
+		return i, UnknownEvent(b[:i])
+	case ansi.ESC:
+		if i >= len(b) || b[i] != '\\' {
+			if cmd == -1 || (start == 0 && end == 2) {
+				return 2, defaultKey()
+			}
+
+			// If we don't have a valid ST terminator, then this is a
+			// cancelled sequence and should be ignored.
+			return i, UnknownEvent(b[:i])
+		}
+
+		i++
+	}
+
+	if end <= start {
+		return i, UnknownEvent(b[:i])
+	}
+
+	// PERFORMANCE: Only allocate the data string if we know we have a handler
+	// for the command. This avoids allocations for unknown OSC sequences that
+	// can be sent in high frequency by trackpads.
+	switch cmd {
+	case 10, 11, 12:
+		data := string(b[start:end])
+		color := ansi.XParseColor(data)
+		switch cmd {
+		case 10:
+			return i, ForegroundColorEvent{color}
+		case 11:
+			return i, BackgroundColorEvent{color}
+		case 12:
+			return i, CursorColorEvent{color}
+		}
+	case 52:
+		data := string(b[start:end])
+		parts := strings.Split(data, ";")
+		if len(parts) == 0 {
+			return i, ClipboardEvent{}
+		}
+		if len(parts) != 2 || len(parts[0]) < 1 {
+			break
+		}
+
+		b64 := parts[1]
+		bts, err := base64.StdEncoding.DecodeString(b64)
+		if err != nil {
+			break
+		}
+
+		sel := ClipboardSelection(parts[0][0]) //nolint:unconvert
+		return i, ClipboardEvent{Selection: sel, Content: string(bts)}
+	}
+
+	return i, UnknownEvent(b[:i])
+}
+
+// parseStTerminated parses a control sequence that gets terminated by a ST character.
+func (p *Parser) parseStTerminated(
+	intro8, intro7 byte,
+	fn func([]byte) Event,
+) func([]byte) (int, Event) {
+	defaultKey := func(b []byte) (int, Event) {
+		switch intro8 {
+		case ansi.SOS:
+			return 2, KeyPressEvent{Code: 'x', Mod: ModShift | ModAlt}
+		case ansi.PM, ansi.APC:
+			return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+		}
+		return 0, nil
+	}
+	return func(b []byte) (int, Event) {
+		if len(b) == 2 && b[0] == ansi.ESC {
+			return defaultKey(b)
+		}
+
+		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 \)
+		start := i
+		for ; i < len(b); i++ {
+			if slices.Contains([]byte{ansi.ESC, ansi.ST, ansi.CAN, ansi.SUB}, b[i]) {
+				break
+			}
+		}
+
+		if i >= len(b) {
+			// Incomplete sequence
+			return 0, nil
+		}
+
+		end := i // end of the sequence data
+		i++
+
+		// Check 7-bit ST (string terminator) character
+		switch b[i-1] {
+		case ansi.CAN, ansi.SUB:
+			return i, UnknownEvent(b[:i])
+		case ansi.ESC:
+			if i >= len(b) || b[i] != '\\' {
+				if start == end {
+					return defaultKey(b)
+				}
+
+				// If we don't have a valid ST terminator, then this is a
+				// cancelled sequence and should be ignored.
+				return i, UnknownEvent(b[:i])
+			}
+
+			i++
+		}
+
+		// Call the function to parse the sequence and return the result
+		if fn != nil {
+			if e := fn(b[start:end]); e != nil {
+				return i, e
+			}
+		}
+
+		return i, UnknownEvent(b[:i])
+	}
+}
+
+func (p *Parser) parseDcs(b []byte) (int, Event) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+P key
+		return 2, KeyPressEvent{Code: 'p', Mod: ModShift | ModAlt}
+	}
+
+	var params [16]ansi.Param
+	var paramsLen int
+	var cmd ansi.Cmd
+
+	// 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] <= '?' {
+		cmd |= ansi.Cmd(b[i]) << parser.PrefixShift
+	}
+
+	// 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] += ansi.Param(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
+	cmd |= ansi.Cmd(intermed) << parser.IntermedShift
+
+	// Scan final byte in the range 0x40-0x7E
+	if i >= len(b) {
+		// Incomplete sequence
+		return 0, nil
+	}
+	if b[i] < 0x40 || b[i] > 0x7E {
+		return i, UnknownEvent(b[:i])
+	}
+
+	// Add the final byte
+	cmd |= ansi.Cmd(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) {
+		// Incomplete sequence
+		return 0, nil
+	}
+
+	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++
+	}
+
+	pa := ansi.Params(params[:paramsLen])
+	switch cmd {
+	case 'r' | '+'<<parser.IntermedShift:
+		// XTGETTCAP responses
+		param, _, _ := pa.Param(0, 0)
+		switch param {
+		case 1: // 1 means valid response, 0 means invalid response
+			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\\"
+			// We ignore invalid responses and only send valid ones to the program.
+			//
+			// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+			return i, tc
+		}
+	case '|' | '>'<<parser.PrefixShift:
+		// XTVersion response
+		return i, TerminalVersionEvent(b[start:end])
+	}
+
+	return i, UnknownEvent(b[:i])
+}
+
+func (p *Parser) parseApc(b []byte) (int, Event) {
+	if len(b) == 2 && b[0] == ansi.ESC {
+		// short cut if this is an alt+_ key
+		return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+	}
+
+	// APC sequences are introduced by APC (0x9f) or ESC _ (0x1b 0x5f)
+	return p.parseStTerminated(ansi.APC, '_', func(b []byte) Event {
+		if len(b) == 0 {
+			return nil
+		}
+
+		switch b[0] {
+		case 'G': // Kitty Graphics Protocol
+			var g KittyGraphicsEvent
+			parts := bytes.Split(b[1:], []byte{';'})
+			g.Options.UnmarshalText(parts[0]) //nolint:errcheck,gosec
+			if len(parts) > 1 {
+				g.Payload = parts[1]
+			}
+			return g
+		}
+
+		return nil
+	})(b)
+}
+
+func (p *Parser) parseUtf8(b []byte) (int, Event) {
+	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, p.parseControl(c)
+	} else if c > ansi.US && c < ansi.DEL {
+		// ASCII printable characters
+		code := rune(c)
+		k := KeyPressEvent{Code: code, Text: string(code)}
+		if unicode.IsUpper(code) {
+			// Convert upper case letters to lower case + shift modifier
+			k.Code = unicode.ToLower(code)
+			k.ShiftedCode = code
+			k.Mod |= ModShift
+		}
+
+		return 1, k
+	}
+
+	code, _ := utf8.DecodeRune(b)
+	if code == utf8.RuneError {
+		return 1, UnknownEvent(b[0])
+	}
+
+	cluster, _, _, _ := uniseg.FirstGraphemeCluster(b, -1)
+	// PERFORMANCE: Use RuneCount to check for multi-rune graphemes instead of
+	// looping over the string representation.
+	if utf8.RuneCount(cluster) > 1 {
+		code = KeyExtended
+	}
+
+	return len(cluster), KeyPressEvent{Code: code, Text: string(cluster)}
+}
+
+func (p *Parser) parseControl(b byte) Event {
+	switch b {
+	case ansi.NUL:
+		if p.flags&FlagCtrlAt != 0 {
+			return KeyPressEvent{Code: '@', Mod: ModCtrl}
+		}
+		return KeyPressEvent{Code: KeySpace, Mod: ModCtrl}
+	case ansi.BS:
+		return KeyPressEvent{Code: 'h', Mod: ModCtrl}
+	case ansi.HT:
+		if p.flags&FlagCtrlI != 0 {
+			return KeyPressEvent{Code: 'i', Mod: ModCtrl}
+		}
+		return KeyPressEvent{Code: KeyTab}
+	case ansi.CR:
+		if p.flags&FlagCtrlM != 0 {
+			return KeyPressEvent{Code: 'm', Mod: ModCtrl}
+		}
+		return KeyPressEvent{Code: KeyEnter}
+	case ansi.ESC:
+		if p.flags&FlagCtrlOpenBracket != 0 {
+			return KeyPressEvent{Code: '[', Mod: ModCtrl}
+		}
+		return KeyPressEvent{Code: KeyEscape}
+	case ansi.DEL:
+		if p.flags&FlagBackspace != 0 {
+			return KeyPressEvent{Code: KeyDelete}
+		}
+		return KeyPressEvent{Code: KeyBackspace}
+	case ansi.SP:
+		return KeyPressEvent{Code: KeySpace, Text: " "}
+	default:
+		if b >= ansi.SOH && b <= ansi.SUB {
+			// Use lower case letters for control codes
+			code := rune(b + 0x60)
+			return KeyPressEvent{Code: code, Mod: ModCtrl}
+		} else if b >= ansi.FS && b <= ansi.US {
+			code := rune(b + 0x40)
+			return KeyPressEvent{Code: code, Mod: ModCtrl}
+		}
+		return UnknownEvent(b)
+	}
+}

+ 47 - 0
packages/tui/input/parse_test.go

@@ -0,0 +1,47 @@
+package input
+
+import (
+	"image/color"
+	"reflect"
+	"testing"
+
+	"github.com/charmbracelet/x/ansi"
+)
+
+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\x1b[4;1$y")
+	want := []Event{
+		KeyPressEvent{Code: KeyTab, Mod: ModShift | ModAlt},
+		KeyPressEvent{Code: 't', Text: "t"},
+		KeyPressEvent{Code: 'e', Text: "e"},
+		KeyPressEvent{Code: 's', Text: "s"},
+		KeyPressEvent{Code: 't', Text: "t"},
+		KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
+		ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}},
+		KeyPressEvent{Code: KeyEscape, Mod: ModShift},
+		ModeReportEvent{Mode: ansi.AltScreenSaveCursorMode, Value: ansi.ModeReset},
+		ModeReportEvent{Mode: ansi.InsertReplaceMode, Value: ansi.ModeSet},
+	}
+
+	var p Parser
+	for i := 0; len(input) != 0; i++ {
+		if i >= len(want) {
+			t.Fatalf("reached end of want events")
+		}
+		n, got := p.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) {
+	var p Parser
+	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++ {
+		p.parseSequence(input)
+	}
+}

+ 13 - 0
packages/tui/input/paste.go

@@ -0,0 +1,13 @@
+package input
+
+// PasteEvent is an message that is emitted when a terminal receives pasted text
+// using bracketed-paste.
+type PasteEvent string
+
+// PasteStartEvent is an message that is emitted when the terminal starts the
+// bracketed-paste text.
+type PasteStartEvent struct{}
+
+// PasteEndEvent is an message that is emitted when the terminal ends the
+// bracketed-paste text.
+type PasteEndEvent struct{}

+ 389 - 0
packages/tui/input/table.go

@@ -0,0 +1,389 @@
+package input
+
+import (
+	"maps"
+	"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.
+func buildKeysTable(flags int, term string) map[string]Key {
+	nul := Key{Code: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space
+	if flags&FlagCtrlAt != 0 {
+		nul = Key{Code: '@', Mod: ModCtrl}
+	}
+
+	tab := Key{Code: KeyTab} // ctrl+i or tab
+	if flags&FlagCtrlI != 0 {
+		tab = Key{Code: 'i', Mod: ModCtrl}
+	}
+
+	enter := Key{Code: KeyEnter} // ctrl+m or enter
+	if flags&FlagCtrlM != 0 {
+		enter = Key{Code: 'm', Mod: ModCtrl}
+	}
+
+	esc := Key{Code: KeyEscape} // ctrl+[ or escape
+	if flags&FlagCtrlOpenBracket != 0 {
+		esc = Key{Code: '[', Mod: ModCtrl} // ctrl+[ or escape
+	}
+
+	del := Key{Code: KeyBackspace}
+	if flags&FlagBackspace != 0 {
+		del.Code = KeyDelete
+	}
+
+	find := Key{Code: KeyHome}
+	if flags&FlagFind != 0 {
+		find.Code = KeyFind
+	}
+
+	sel := Key{Code: KeyEnd}
+	if flags&FlagSelect != 0 {
+		sel.Code = 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)): {Code: 'a', Mod: ModCtrl},
+		string(byte(ansi.STX)): {Code: 'b', Mod: ModCtrl},
+		string(byte(ansi.ETX)): {Code: 'c', Mod: ModCtrl},
+		string(byte(ansi.EOT)): {Code: 'd', Mod: ModCtrl},
+		string(byte(ansi.ENQ)): {Code: 'e', Mod: ModCtrl},
+		string(byte(ansi.ACK)): {Code: 'f', Mod: ModCtrl},
+		string(byte(ansi.BEL)): {Code: 'g', Mod: ModCtrl},
+		string(byte(ansi.BS)):  {Code: 'h', Mod: ModCtrl},
+		string(byte(ansi.HT)):  tab,
+		string(byte(ansi.LF)):  {Code: 'j', Mod: ModCtrl},
+		string(byte(ansi.VT)):  {Code: 'k', Mod: ModCtrl},
+		string(byte(ansi.FF)):  {Code: 'l', Mod: ModCtrl},
+		string(byte(ansi.CR)):  enter,
+		string(byte(ansi.SO)):  {Code: 'n', Mod: ModCtrl},
+		string(byte(ansi.SI)):  {Code: 'o', Mod: ModCtrl},
+		string(byte(ansi.DLE)): {Code: 'p', Mod: ModCtrl},
+		string(byte(ansi.DC1)): {Code: 'q', Mod: ModCtrl},
+		string(byte(ansi.DC2)): {Code: 'r', Mod: ModCtrl},
+		string(byte(ansi.DC3)): {Code: 's', Mod: ModCtrl},
+		string(byte(ansi.DC4)): {Code: 't', Mod: ModCtrl},
+		string(byte(ansi.NAK)): {Code: 'u', Mod: ModCtrl},
+		string(byte(ansi.SYN)): {Code: 'v', Mod: ModCtrl},
+		string(byte(ansi.ETB)): {Code: 'w', Mod: ModCtrl},
+		string(byte(ansi.CAN)): {Code: 'x', Mod: ModCtrl},
+		string(byte(ansi.EM)):  {Code: 'y', Mod: ModCtrl},
+		string(byte(ansi.SUB)): {Code: 'z', Mod: ModCtrl},
+		string(byte(ansi.ESC)): esc,
+		string(byte(ansi.FS)):  {Code: '\\', Mod: ModCtrl},
+		string(byte(ansi.GS)):  {Code: ']', Mod: ModCtrl},
+		string(byte(ansi.RS)):  {Code: '^', Mod: ModCtrl},
+		string(byte(ansi.US)):  {Code: '_', Mod: ModCtrl},
+
+		// Special keys in G0
+		string(byte(ansi.SP)):  {Code: KeySpace, Text: " "},
+		string(byte(ansi.DEL)): del,
+
+		// Special keys
+
+		"\x1b[Z": {Code: KeyTab, Mod: ModShift},
+
+		"\x1b[1~": find,
+		"\x1b[2~": {Code: KeyInsert},
+		"\x1b[3~": {Code: KeyDelete},
+		"\x1b[4~": sel,
+		"\x1b[5~": {Code: KeyPgUp},
+		"\x1b[6~": {Code: KeyPgDown},
+		"\x1b[7~": {Code: KeyHome},
+		"\x1b[8~": {Code: KeyEnd},
+
+		// Normal mode
+		"\x1b[A": {Code: KeyUp},
+		"\x1b[B": {Code: KeyDown},
+		"\x1b[C": {Code: KeyRight},
+		"\x1b[D": {Code: KeyLeft},
+		"\x1b[E": {Code: KeyBegin},
+		"\x1b[F": {Code: KeyEnd},
+		"\x1b[H": {Code: KeyHome},
+		"\x1b[P": {Code: KeyF1},
+		"\x1b[Q": {Code: KeyF2},
+		"\x1b[R": {Code: KeyF3},
+		"\x1b[S": {Code: KeyF4},
+
+		// Application Cursor Key Mode (DECCKM)
+		"\x1bOA": {Code: KeyUp},
+		"\x1bOB": {Code: KeyDown},
+		"\x1bOC": {Code: KeyRight},
+		"\x1bOD": {Code: KeyLeft},
+		"\x1bOE": {Code: KeyBegin},
+		"\x1bOF": {Code: KeyEnd},
+		"\x1bOH": {Code: KeyHome},
+		"\x1bOP": {Code: KeyF1},
+		"\x1bOQ": {Code: KeyF2},
+		"\x1bOR": {Code: KeyF3},
+		"\x1bOS": {Code: KeyF4},
+
+		// Keypad Application Mode (DECKPAM)
+
+		"\x1bOM": {Code: KeyKpEnter},
+		"\x1bOX": {Code: KeyKpEqual},
+		"\x1bOj": {Code: KeyKpMultiply},
+		"\x1bOk": {Code: KeyKpPlus},
+		"\x1bOl": {Code: KeyKpComma},
+		"\x1bOm": {Code: KeyKpMinus},
+		"\x1bOn": {Code: KeyKpDecimal},
+		"\x1bOo": {Code: KeyKpDivide},
+		"\x1bOp": {Code: KeyKp0},
+		"\x1bOq": {Code: KeyKp1},
+		"\x1bOr": {Code: KeyKp2},
+		"\x1bOs": {Code: KeyKp3},
+		"\x1bOt": {Code: KeyKp4},
+		"\x1bOu": {Code: KeyKp5},
+		"\x1bOv": {Code: KeyKp6},
+		"\x1bOw": {Code: KeyKp7},
+		"\x1bOx": {Code: KeyKp8},
+		"\x1bOy": {Code: KeyKp9},
+
+		// Function keys
+
+		"\x1b[11~": {Code: KeyF1},
+		"\x1b[12~": {Code: KeyF2},
+		"\x1b[13~": {Code: KeyF3},
+		"\x1b[14~": {Code: KeyF4},
+		"\x1b[15~": {Code: KeyF5},
+		"\x1b[17~": {Code: KeyF6},
+		"\x1b[18~": {Code: KeyF7},
+		"\x1b[19~": {Code: KeyF8},
+		"\x1b[20~": {Code: KeyF9},
+		"\x1b[21~": {Code: KeyF10},
+		"\x1b[23~": {Code: KeyF11},
+		"\x1b[24~": {Code: KeyF12},
+		"\x1b[25~": {Code: KeyF13},
+		"\x1b[26~": {Code: KeyF14},
+		"\x1b[28~": {Code: KeyF15},
+		"\x1b[29~": {Code: KeyF16},
+		"\x1b[31~": {Code: KeyF17},
+		"\x1b[32~": {Code: KeyF18},
+		"\x1b[33~": {Code: KeyF19},
+		"\x1b[34~": {Code: KeyF20},
+	}
+
+	// CSI ~ sequence keys
+	csiTildeKeys := map[string]Key{
+		"1": find, "2": {Code: KeyInsert},
+		"3": {Code: KeyDelete}, "4": sel,
+		"5": {Code: KeyPgUp}, "6": {Code: KeyPgDown},
+		"7": {Code: KeyHome}, "8": {Code: KeyEnd},
+		// There are no 9 and 10 keys
+		"11": {Code: KeyF1}, "12": {Code: KeyF2},
+		"13": {Code: KeyF3}, "14": {Code: KeyF4},
+		"15": {Code: KeyF5}, "17": {Code: KeyF6},
+		"18": {Code: KeyF7}, "19": {Code: KeyF8},
+		"20": {Code: KeyF9}, "21": {Code: KeyF10},
+		"23": {Code: KeyF11}, "24": {Code: KeyF12},
+		"25": {Code: KeyF13}, "26": {Code: KeyF14},
+		"28": {Code: KeyF15}, "29": {Code: KeyF16},
+		"31": {Code: KeyF17}, "32": {Code: KeyF18},
+		"33": {Code: KeyF19}, "34": {Code: KeyF20},
+	}
+
+	// URxvt keys
+	// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
+	table["\x1b[a"] = Key{Code: KeyUp, Mod: ModShift}
+	table["\x1b[b"] = Key{Code: KeyDown, Mod: ModShift}
+	table["\x1b[c"] = Key{Code: KeyRight, Mod: ModShift}
+	table["\x1b[d"] = Key{Code: KeyLeft, Mod: ModShift}
+	table["\x1bOa"] = Key{Code: KeyUp, Mod: ModCtrl}
+	table["\x1bOb"] = Key{Code: KeyDown, Mod: ModCtrl}
+	table["\x1bOc"] = Key{Code: KeyRight, Mod: ModCtrl}
+	table["\x1bOd"] = Key{Code: KeyLeft, Mod: ModCtrl}
+	//nolint:godox
+	// 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{Code: KeyF11, Mod: ModShift}
+	table["\x1b[24$"] = Key{Code: KeyF12, Mod: ModShift}
+	table["\x1b[25$"] = Key{Code: KeyF13, Mod: ModShift}
+	table["\x1b[26$"] = Key{Code: KeyF14, Mod: ModShift}
+	table["\x1b[28$"] = Key{Code: KeyF15, Mod: ModShift}
+	table["\x1b[29$"] = Key{Code: KeyF16, Mod: ModShift}
+	table["\x1b[31$"] = Key{Code: KeyF17, Mod: ModShift}
+	table["\x1b[32$"] = Key{Code: KeyF18, Mod: ModShift}
+	table["\x1b[33$"] = Key{Code: KeyF19, Mod: ModShift}
+	table["\x1b[34$"] = Key{Code: KeyF20, Mod: ModShift}
+	table["\x1b[11^"] = Key{Code: KeyF1, Mod: ModCtrl}
+	table["\x1b[12^"] = Key{Code: KeyF2, Mod: ModCtrl}
+	table["\x1b[13^"] = Key{Code: KeyF3, Mod: ModCtrl}
+	table["\x1b[14^"] = Key{Code: KeyF4, Mod: ModCtrl}
+	table["\x1b[15^"] = Key{Code: KeyF5, Mod: ModCtrl}
+	table["\x1b[17^"] = Key{Code: KeyF6, Mod: ModCtrl}
+	table["\x1b[18^"] = Key{Code: KeyF7, Mod: ModCtrl}
+	table["\x1b[19^"] = Key{Code: KeyF8, Mod: ModCtrl}
+	table["\x1b[20^"] = Key{Code: KeyF9, Mod: ModCtrl}
+	table["\x1b[21^"] = Key{Code: KeyF10, Mod: ModCtrl}
+	table["\x1b[23^"] = Key{Code: KeyF11, Mod: ModCtrl}
+	table["\x1b[24^"] = Key{Code: KeyF12, Mod: ModCtrl}
+	table["\x1b[25^"] = Key{Code: KeyF13, Mod: ModCtrl}
+	table["\x1b[26^"] = Key{Code: KeyF14, Mod: ModCtrl}
+	table["\x1b[28^"] = Key{Code: KeyF15, Mod: ModCtrl}
+	table["\x1b[29^"] = Key{Code: KeyF16, Mod: ModCtrl}
+	table["\x1b[31^"] = Key{Code: KeyF17, Mod: ModCtrl}
+	table["\x1b[32^"] = Key{Code: KeyF18, Mod: ModCtrl}
+	table["\x1b[33^"] = Key{Code: KeyF19, Mod: ModCtrl}
+	table["\x1b[34^"] = Key{Code: KeyF20, Mod: ModCtrl}
+	table["\x1b[23@"] = Key{Code: KeyF11, Mod: ModShift | ModCtrl}
+	table["\x1b[24@"] = Key{Code: KeyF12, Mod: ModShift | ModCtrl}
+	table["\x1b[25@"] = Key{Code: KeyF13, Mod: ModShift | ModCtrl}
+	table["\x1b[26@"] = Key{Code: KeyF14, Mod: ModShift | ModCtrl}
+	table["\x1b[28@"] = Key{Code: KeyF15, Mod: ModShift | ModCtrl}
+	table["\x1b[29@"] = Key{Code: KeyF16, Mod: ModShift | ModCtrl}
+	table["\x1b[31@"] = Key{Code: KeyF17, Mod: ModShift | ModCtrl}
+	table["\x1b[32@"] = Key{Code: KeyF18, Mod: ModShift | ModCtrl}
+	table["\x1b[33@"] = Key{Code: KeyF19, Mod: ModShift | ModCtrl}
+	table["\x1b[34@"] = Key{Code: 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
+		key.Text = "" // Clear runes
+		tmap["\x1b"+seq] = key
+	}
+	maps.Copy(table, tmap)
+
+	// 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": {Code: KeyKpEnter}, "X": {Code: KeyKpEqual},
+		"j": {Code: KeyKpMultiply}, "k": {Code: KeyKpPlus},
+		"l": {Code: KeyKpComma}, "m": {Code: KeyKpMinus},
+		"n": {Code: KeyKpDecimal}, "o": {Code: KeyKpDivide},
+		"p": {Code: KeyKp0}, "q": {Code: KeyKp1},
+		"r": {Code: KeyKp2}, "s": {Code: KeyKp3},
+		"t": {Code: KeyKp4}, "u": {Code: KeyKp5},
+		"v": {Code: KeyKp6}, "w": {Code: KeyKp7},
+		"x": {Code: KeyKp8}, "y": {Code: KeyKp9},
+	}
+
+	// XTerm keys
+	csiFuncKeys := map[string]Key{
+		"A": {Code: KeyUp}, "B": {Code: KeyDown},
+		"C": {Code: KeyRight}, "D": {Code: KeyLeft},
+		"E": {Code: KeyBegin}, "F": {Code: KeyEnd},
+		"H": {Code: KeyHome}, "P": {Code: KeyF1},
+		"Q": {Code: KeyF2}, "R": {Code: KeyF3},
+		"S": {Code: KeyF4},
+	}
+
+	// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
+	modifyOtherKeys := map[int]Key{
+		ansi.BS:  {Code: KeyBackspace},
+		ansi.HT:  {Code: KeyTab},
+		ansi.CR:  {Code: KeyEnter},
+		ansi.ESC: {Code: KeyEscape},
+		ansi.DEL: {Code: 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)
+		maps.Copy(table, titable)
+	}
+
+	return table
+}

+ 54 - 0
packages/tui/input/termcap.go

@@ -0,0 +1,54 @@
+package input
+
+import (
+	"bytes"
+	"encoding/hex"
+	"strings"
+)
+
+// CapabilityEvent represents a Termcap/Terminfo 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 CapabilityEvent string
+
+func parseTermcap(data []byte) CapabilityEvent {
+	// XTGETTCAP
+	if len(data) == 0 {
+		return CapabilityEvent("")
+	}
+
+	var tc strings.Builder
+	split := bytes.Split(data, []byte{';'})
+	for _, s := range split {
+		parts := bytes.SplitN(s, []byte{'='}, 2)
+		if len(parts) == 0 {
+			return CapabilityEvent("")
+		}
+
+		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 CapabilityEvent(tc.String())
+}

+ 277 - 0
packages/tui/input/terminfo.go

@@ -0,0 +1,277 @@
+package input
+
+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": {Code: KeyUp},
+		"kUP":   {Code: KeyUp, Mod: ModShift},
+		"kUP3":  {Code: KeyUp, Mod: ModAlt},
+		"kUP4":  {Code: KeyUp, Mod: ModShift | ModAlt},
+		"kUP5":  {Code: KeyUp, Mod: ModCtrl},
+		"kUP6":  {Code: KeyUp, Mod: ModShift | ModCtrl},
+		"kUP7":  {Code: KeyUp, Mod: ModAlt | ModCtrl},
+		"kUP8":  {Code: KeyUp, Mod: ModShift | ModAlt | ModCtrl},
+		"kcud1": {Code: KeyDown},
+		"kDN":   {Code: KeyDown, Mod: ModShift},
+		"kDN3":  {Code: KeyDown, Mod: ModAlt},
+		"kDN4":  {Code: KeyDown, Mod: ModShift | ModAlt},
+		"kDN5":  {Code: KeyDown, Mod: ModCtrl},
+		"kDN7":  {Code: KeyDown, Mod: ModAlt | ModCtrl},
+		"kDN6":  {Code: KeyDown, Mod: ModShift | ModCtrl},
+		"kDN8":  {Code: KeyDown, Mod: ModShift | ModAlt | ModCtrl},
+		"kcub1": {Code: KeyLeft},
+		"kLFT":  {Code: KeyLeft, Mod: ModShift},
+		"kLFT3": {Code: KeyLeft, Mod: ModAlt},
+		"kLFT4": {Code: KeyLeft, Mod: ModShift | ModAlt},
+		"kLFT5": {Code: KeyLeft, Mod: ModCtrl},
+		"kLFT6": {Code: KeyLeft, Mod: ModShift | ModCtrl},
+		"kLFT7": {Code: KeyLeft, Mod: ModAlt | ModCtrl},
+		"kLFT8": {Code: KeyLeft, Mod: ModShift | ModAlt | ModCtrl},
+		"kcuf1": {Code: KeyRight},
+		"kRIT":  {Code: KeyRight, Mod: ModShift},
+		"kRIT3": {Code: KeyRight, Mod: ModAlt},
+		"kRIT4": {Code: KeyRight, Mod: ModShift | ModAlt},
+		"kRIT5": {Code: KeyRight, Mod: ModCtrl},
+		"kRIT6": {Code: KeyRight, Mod: ModShift | ModCtrl},
+		"kRIT7": {Code: KeyRight, Mod: ModAlt | ModCtrl},
+		"kRIT8": {Code: KeyRight, Mod: ModShift | ModAlt | ModCtrl},
+		"kich1": {Code: KeyInsert},
+		"kIC":   {Code: KeyInsert, Mod: ModShift},
+		"kIC3":  {Code: KeyInsert, Mod: ModAlt},
+		"kIC4":  {Code: KeyInsert, Mod: ModShift | ModAlt},
+		"kIC5":  {Code: KeyInsert, Mod: ModCtrl},
+		"kIC6":  {Code: KeyInsert, Mod: ModShift | ModCtrl},
+		"kIC7":  {Code: KeyInsert, Mod: ModAlt | ModCtrl},
+		"kIC8":  {Code: KeyInsert, Mod: ModShift | ModAlt | ModCtrl},
+		"kdch1": {Code: KeyDelete},
+		"kDC":   {Code: KeyDelete, Mod: ModShift},
+		"kDC3":  {Code: KeyDelete, Mod: ModAlt},
+		"kDC4":  {Code: KeyDelete, Mod: ModShift | ModAlt},
+		"kDC5":  {Code: KeyDelete, Mod: ModCtrl},
+		"kDC6":  {Code: KeyDelete, Mod: ModShift | ModCtrl},
+		"kDC7":  {Code: KeyDelete, Mod: ModAlt | ModCtrl},
+		"kDC8":  {Code: KeyDelete, Mod: ModShift | ModAlt | ModCtrl},
+		"khome": {Code: KeyHome},
+		"kHOM":  {Code: KeyHome, Mod: ModShift},
+		"kHOM3": {Code: KeyHome, Mod: ModAlt},
+		"kHOM4": {Code: KeyHome, Mod: ModShift | ModAlt},
+		"kHOM5": {Code: KeyHome, Mod: ModCtrl},
+		"kHOM6": {Code: KeyHome, Mod: ModShift | ModCtrl},
+		"kHOM7": {Code: KeyHome, Mod: ModAlt | ModCtrl},
+		"kHOM8": {Code: KeyHome, Mod: ModShift | ModAlt | ModCtrl},
+		"kend":  {Code: KeyEnd},
+		"kEND":  {Code: KeyEnd, Mod: ModShift},
+		"kEND3": {Code: KeyEnd, Mod: ModAlt},
+		"kEND4": {Code: KeyEnd, Mod: ModShift | ModAlt},
+		"kEND5": {Code: KeyEnd, Mod: ModCtrl},
+		"kEND6": {Code: KeyEnd, Mod: ModShift | ModCtrl},
+		"kEND7": {Code: KeyEnd, Mod: ModAlt | ModCtrl},
+		"kEND8": {Code: KeyEnd, Mod: ModShift | ModAlt | ModCtrl},
+		"kpp":   {Code: KeyPgUp},
+		"kprv":  {Code: KeyPgUp},
+		"kPRV":  {Code: KeyPgUp, Mod: ModShift},
+		"kPRV3": {Code: KeyPgUp, Mod: ModAlt},
+		"kPRV4": {Code: KeyPgUp, Mod: ModShift | ModAlt},
+		"kPRV5": {Code: KeyPgUp, Mod: ModCtrl},
+		"kPRV6": {Code: KeyPgUp, Mod: ModShift | ModCtrl},
+		"kPRV7": {Code: KeyPgUp, Mod: ModAlt | ModCtrl},
+		"kPRV8": {Code: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl},
+		"knp":   {Code: KeyPgDown},
+		"knxt":  {Code: KeyPgDown},
+		"kNXT":  {Code: KeyPgDown, Mod: ModShift},
+		"kNXT3": {Code: KeyPgDown, Mod: ModAlt},
+		"kNXT4": {Code: KeyPgDown, Mod: ModShift | ModAlt},
+		"kNXT5": {Code: KeyPgDown, Mod: ModCtrl},
+		"kNXT6": {Code: KeyPgDown, Mod: ModShift | ModCtrl},
+		"kNXT7": {Code: KeyPgDown, Mod: ModAlt | ModCtrl},
+		"kNXT8": {Code: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl},
+
+		"kbs":  {Code: KeyBackspace},
+		"kcbt": {Code: 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":  {Code: KeyF1},
+		"kf2":  {Code: KeyF2},
+		"kf3":  {Code: KeyF3},
+		"kf4":  {Code: KeyF4},
+		"kf5":  {Code: KeyF5},
+		"kf6":  {Code: KeyF6},
+		"kf7":  {Code: KeyF7},
+		"kf8":  {Code: KeyF8},
+		"kf9":  {Code: KeyF9},
+		"kf10": {Code: KeyF10},
+		"kf11": {Code: KeyF11},
+		"kf12": {Code: KeyF12},
+		"kf13": {Code: KeyF1, Mod: ModShift},
+		"kf14": {Code: KeyF2, Mod: ModShift},
+		"kf15": {Code: KeyF3, Mod: ModShift},
+		"kf16": {Code: KeyF4, Mod: ModShift},
+		"kf17": {Code: KeyF5, Mod: ModShift},
+		"kf18": {Code: KeyF6, Mod: ModShift},
+		"kf19": {Code: KeyF7, Mod: ModShift},
+		"kf20": {Code: KeyF8, Mod: ModShift},
+		"kf21": {Code: KeyF9, Mod: ModShift},
+		"kf22": {Code: KeyF10, Mod: ModShift},
+		"kf23": {Code: KeyF11, Mod: ModShift},
+		"kf24": {Code: KeyF12, Mod: ModShift},
+		"kf25": {Code: KeyF1, Mod: ModCtrl},
+		"kf26": {Code: KeyF2, Mod: ModCtrl},
+		"kf27": {Code: KeyF3, Mod: ModCtrl},
+		"kf28": {Code: KeyF4, Mod: ModCtrl},
+		"kf29": {Code: KeyF5, Mod: ModCtrl},
+		"kf30": {Code: KeyF6, Mod: ModCtrl},
+		"kf31": {Code: KeyF7, Mod: ModCtrl},
+		"kf32": {Code: KeyF8, Mod: ModCtrl},
+		"kf33": {Code: KeyF9, Mod: ModCtrl},
+		"kf34": {Code: KeyF10, Mod: ModCtrl},
+		"kf35": {Code: KeyF11, Mod: ModCtrl},
+		"kf36": {Code: KeyF12, Mod: ModCtrl},
+		"kf37": {Code: KeyF1, Mod: ModShift | ModCtrl},
+		"kf38": {Code: KeyF2, Mod: ModShift | ModCtrl},
+		"kf39": {Code: KeyF3, Mod: ModShift | ModCtrl},
+		"kf40": {Code: KeyF4, Mod: ModShift | ModCtrl},
+		"kf41": {Code: KeyF5, Mod: ModShift | ModCtrl},
+		"kf42": {Code: KeyF6, Mod: ModShift | ModCtrl},
+		"kf43": {Code: KeyF7, Mod: ModShift | ModCtrl},
+		"kf44": {Code: KeyF8, Mod: ModShift | ModCtrl},
+		"kf45": {Code: KeyF9, Mod: ModShift | ModCtrl},
+		"kf46": {Code: KeyF10, Mod: ModShift | ModCtrl},
+		"kf47": {Code: KeyF11, Mod: ModShift | ModCtrl},
+		"kf48": {Code: KeyF12, Mod: ModShift | ModCtrl},
+		"kf49": {Code: KeyF1, Mod: ModAlt},
+		"kf50": {Code: KeyF2, Mod: ModAlt},
+		"kf51": {Code: KeyF3, Mod: ModAlt},
+		"kf52": {Code: KeyF4, Mod: ModAlt},
+		"kf53": {Code: KeyF5, Mod: ModAlt},
+		"kf54": {Code: KeyF6, Mod: ModAlt},
+		"kf55": {Code: KeyF7, Mod: ModAlt},
+		"kf56": {Code: KeyF8, Mod: ModAlt},
+		"kf57": {Code: KeyF9, Mod: ModAlt},
+		"kf58": {Code: KeyF10, Mod: ModAlt},
+		"kf59": {Code: KeyF11, Mod: ModAlt},
+		"kf60": {Code: KeyF12, Mod: ModAlt},
+		"kf61": {Code: KeyF1, Mod: ModShift | ModAlt},
+		"kf62": {Code: KeyF2, Mod: ModShift | ModAlt},
+		"kf63": {Code: 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{Code: KeyF13}
+		keys["kf14"] = Key{Code: KeyF14}
+		keys["kf15"] = Key{Code: KeyF15}
+		keys["kf16"] = Key{Code: KeyF16}
+		keys["kf17"] = Key{Code: KeyF17}
+		keys["kf18"] = Key{Code: KeyF18}
+		keys["kf19"] = Key{Code: KeyF19}
+		keys["kf20"] = Key{Code: KeyF20}
+		keys["kf21"] = Key{Code: KeyF21}
+		keys["kf22"] = Key{Code: KeyF22}
+		keys["kf23"] = Key{Code: KeyF23}
+		keys["kf24"] = Key{Code: KeyF24}
+		keys["kf25"] = Key{Code: KeyF25}
+		keys["kf26"] = Key{Code: KeyF26}
+		keys["kf27"] = Key{Code: KeyF27}
+		keys["kf28"] = Key{Code: KeyF28}
+		keys["kf29"] = Key{Code: KeyF29}
+		keys["kf30"] = Key{Code: KeyF30}
+		keys["kf31"] = Key{Code: KeyF31}
+		keys["kf32"] = Key{Code: KeyF32}
+		keys["kf33"] = Key{Code: KeyF33}
+		keys["kf34"] = Key{Code: KeyF34}
+		keys["kf35"] = Key{Code: KeyF35}
+		keys["kf36"] = Key{Code: KeyF36}
+		keys["kf37"] = Key{Code: KeyF37}
+		keys["kf38"] = Key{Code: KeyF38}
+		keys["kf39"] = Key{Code: KeyF39}
+		keys["kf40"] = Key{Code: KeyF40}
+		keys["kf41"] = Key{Code: KeyF41}
+		keys["kf42"] = Key{Code: KeyF42}
+		keys["kf43"] = Key{Code: KeyF43}
+		keys["kf44"] = Key{Code: KeyF44}
+		keys["kf45"] = Key{Code: KeyF45}
+		keys["kf46"] = Key{Code: KeyF46}
+		keys["kf47"] = Key{Code: KeyF47}
+		keys["kf48"] = Key{Code: KeyF48}
+		keys["kf49"] = Key{Code: KeyF49}
+		keys["kf50"] = Key{Code: KeyF50}
+		keys["kf51"] = Key{Code: KeyF51}
+		keys["kf52"] = Key{Code: KeyF52}
+		keys["kf53"] = Key{Code: KeyF53}
+		keys["kf54"] = Key{Code: KeyF54}
+		keys["kf55"] = Key{Code: KeyF55}
+		keys["kf56"] = Key{Code: KeyF56}
+		keys["kf57"] = Key{Code: KeyF57}
+		keys["kf58"] = Key{Code: KeyF58}
+		keys["kf59"] = Key{Code: KeyF59}
+		keys["kf60"] = Key{Code: KeyF60}
+		keys["kf61"] = Key{Code: KeyF61}
+		keys["kf62"] = Key{Code: KeyF62}
+		keys["kf63"] = Key{Code: KeyF63}
+	}
+
+	return keys
+}

+ 47 - 0
packages/tui/input/xterm.go

@@ -0,0 +1,47 @@
+package input
+
+import (
+	"github.com/charmbracelet/x/ansi"
+)
+
+func parseXTermModifyOtherKeys(params ansi.Params) Event {
+	// XTerm modify other keys starts with ESC [ 27 ; <modifier> ; <code> ~
+	xmod, _, _ := params.Param(1, 1)
+	xrune, _, _ := params.Param(2, 1)
+	mod := KeyMod(xmod - 1)
+	r := rune(xrune)
+
+	switch r {
+	case ansi.BS:
+		return KeyPressEvent{Mod: mod, Code: KeyBackspace}
+	case ansi.HT:
+		return KeyPressEvent{Mod: mod, Code: KeyTab}
+	case ansi.CR:
+		return KeyPressEvent{Mod: mod, Code: KeyEnter}
+	case ansi.ESC:
+		return KeyPressEvent{Mod: mod, Code: KeyEscape}
+	case ansi.DEL:
+		return KeyPressEvent{Mod: mod, Code: KeyBackspace}
+	}
+
+	// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
+	k := KeyPressEvent{Code: r, Mod: mod}
+	if k.Mod <= ModShift {
+		k.Text = string(r)
+	}
+
+	return k
+}
+
+// TerminalVersionEvent is a message that represents the terminal version.
+type TerminalVersionEvent string
+
+// ModifyOtherKeysEvent represents a modifyOtherKeys event.
+//
+//	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 ModifyOtherKeysEvent uint8

+ 3 - 44
packages/tui/internal/tui/tui.go

@@ -11,6 +11,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/input"
 
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
@@ -74,7 +75,6 @@ type appModel struct {
 	toastManager      *toast.ToastManager
 	interruptKeyState InterruptKeyState
 	exitKeyState      ExitKeyState
-	lastScroll        time.Time
 	messagesRight     bool
 	fileViewer        fileviewer.Model
 	lastMouse         tea.Mouse
@@ -107,44 +107,6 @@ func (a appModel) Init() tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
-var BUGGED_SCROLL_KEYS = map[string]bool{
-	"0": true,
-	"1": true,
-	"2": true,
-	"3": true,
-	"4": true,
-	"5": true,
-	"6": true,
-	"7": true,
-	"8": true,
-	"9": true,
-	"M": true,
-	"m": true,
-	"[": true,
-	";": true,
-	"<": true,
-}
-
-func isScrollRelatedInput(keyString string) bool {
-	if len(keyString) == 0 {
-		return false
-	}
-
-	for _, char := range keyString {
-		charStr := string(char)
-		if !BUGGED_SCROLL_KEYS[charStr] {
-			return false
-		}
-	}
-
-	if len(keyString) > 3 &&
-		(keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') {
-		return true
-	}
-
-	return len(keyString) > 1
-}
-
 func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
@@ -153,10 +115,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyPressMsg:
 		keyString := msg.String()
 
-		if time.Since(a.lastScroll) < time.Millisecond*100 && (BUGGED_SCROLL_KEYS[keyString] || isScrollRelatedInput(keyString)) {
-			return a, nil
-		}
-
 		// 1. Handle active modal
 		if a.modal != nil {
 			switch keyString {
@@ -326,7 +284,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.editor = updatedEditor.(chat.EditorComponent)
 		return a, cmd
 	case tea.MouseWheelMsg:
-		a.lastScroll = time.Now()
 		if a.modal != nil {
 			return a, nil
 		}
@@ -552,6 +509,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.editor.SetExitKeyInDebounce(false)
 	case dialog.FindSelectedMsg:
 		return a.openFile(msg.FilePath)
+	case input.UnknownEvent:
+		return a, nil
 	}
 
 	s, cmd := a.status.Update(msg)