Repository: charmbracelet/bubbletea
Pull request #1122 (2024-08-29 15:27:36)

feat: support focus-blur events
This backports @caarlos0 focus-blur changes from v2-exp to v1 https://github.com/charmbracelet/bubbletea/pull/1081


Patch:
diff --git a/examples/focus-blur/main.go b/examples/focus-blur/main.go
new file mode 100644
index 0000000..155ebae
--- /dev/null
+++ b/examples/focus-blur/main.go
@@ -0,0 +1,66 @@
+package main
+
+// A simple program that handled losing and acquiring focus.
+
+import (
+	"log"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func main() {
+	p := tea.NewProgram(model{
+		// assume we start focused...
+		focused:   true,
+		reporting: true,
+	}, tea.WithReportFocus())
+	if _, err := p.Run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+type model struct {
+	focused   bool
+	reporting bool
+}
+
+func (m model) Init() tea.Cmd {
+	return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.FocusMsg:
+		m.focused = true
+	case tea.BlurMsg:
+		m.focused = false
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "t":
+			m.reporting = !m.reporting
+		case "ctrl+c", "q":
+			return m, tea.Quit
+		}
+	}
+
+	return m, nil
+}
+
+func (m model) View() string {
+	s := "Hi. Focus report is currently "
+	if m.reporting {
+		s += "enabled"
+	} else {
+		s += "disabled"
+	}
+	s += ".\n\n"
+
+	if m.reporting {
+		if m.focused {
+			s += "This program is currently focused!"
+		} else {
+			s += "This program is currently blurred!"
+		}
+	}
+	return s + "\n\nTo quit sooner press ctrl-c, or t to toggle focus reporting...\n"
+}
diff --git a/examples/go.mod b/examples/go.mod
index af11894..cde0c65 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -22,11 +22,9 @@ require (
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymanbagabas/go-udiff v0.2.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
-	github.com/charmbracelet/x/ansi v0.1.4 // indirect
+	github.com/charmbracelet/x/ansi v0.2.3 // indirect
 	github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect
-	github.com/charmbracelet/x/input v0.1.0 // indirect
-	github.com/charmbracelet/x/term v0.1.1 // indirect
-	github.com/charmbracelet/x/windows v0.1.0 // indirect
+	github.com/charmbracelet/x/term v0.2.0 // indirect
 	github.com/dlclark/regexp2 v1.11.0 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -40,7 +38,6 @@ require (
 	github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/sahilm/fuzzy v0.1.1 // indirect
-	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	github.com/yuin/goldmark v1.7.4 // indirect
 	github.com/yuin/goldmark-emoji v1.0.3 // indirect
 	golang.org/x/net v0.27.0 // indirect
diff --git a/examples/go.sum b/examples/go.sum
index f75c3fb..b3d7fa6 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -22,18 +22,14 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
 github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
 github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
 github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
-github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
-github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
+github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
+github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
 github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
 github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/exp/teatest v0.0.0-20240521184646-23081fb03b28 h1:sOWKNRjt8uOEVgPiJVIJCse1+mUDM2F/vYY6W0Go640=
 github.com/charmbracelet/x/exp/teatest v0.0.0-20240521184646-23081fb03b28/go.mod h1:l1w+LTJZCCozeGzMEWGxRw6Mo2DfcZUvupz8HGubdes=
-github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
-github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
-github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
-github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
-github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=
-github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
+github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
+github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -73,15 +69,11 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
-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=
 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
 github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
 github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
 github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
 github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
-golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
-golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
 golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
 golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
 golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
diff --git a/focus.go b/focus.go
new file mode 100644
index 0000000..4d34bea
--- /dev/null
+++ b/focus.go
@@ -0,0 +1,9 @@
+package tea
+
+// FocusMsg represents a terminal focus message.
+// This occurs when the terminal gains focus.
+type FocusMsg struct{}
+
+// BlurMsg represents a terminal blur message.
+// This occurs when the terminal loses focus.
+type BlurMsg struct{}
diff --git a/go.mod b/go.mod
index b03c969..cfe3556 100644
--- a/go.mod
+++ b/go.mod
@@ -4,8 +4,8 @@ go 1.18
 
 require (
 	github.com/charmbracelet/lipgloss v0.13.0
-	github.com/charmbracelet/x/ansi v0.1.4
-	github.com/charmbracelet/x/term v0.1.1
+	github.com/charmbracelet/x/ansi v0.2.3
+	github.com/charmbracelet/x/term v0.2.0
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f
 	github.com/mattn/go-localereader v0.0.1
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
@@ -16,13 +16,10 @@ require (
 
 require (
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
-	github.com/charmbracelet/x/input v0.1.0 // indirect
-	github.com/charmbracelet/x/windows v0.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/muesli/termenv v0.15.2 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
-	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/text v0.3.8 // indirect
 )
diff --git a/go.sum b/go.sum
index e027dcf..890268a 100644
--- a/go.sum
+++ b/go.sum
@@ -2,14 +2,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
 github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
-github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
-github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
-github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
-github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
-github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
-github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
-github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=
-github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
+github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
+github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
+github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
+github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -29,9 +25,6 @@ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1n
 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-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
 golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
 golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/key.go b/key.go
index 89a588a..ab4792a 100644
--- a/key.go
+++ b/key.go
@@ -628,6 +628,13 @@ func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) {
 		}
 	}
 
+	// Detect focus events.
+	var foundRF bool
+	foundRF, w, msg = detectReportFocus(b)
+	if foundRF {
+		return w, msg
+	}
+
 	// Detect bracketed paste.
 	var foundbp bool
 	foundbp, w, msg = detectBracketedPaste(b)
diff --git a/key_sequences.go b/key_sequences.go
index 4ba0f79..15483ef 100644
--- a/key_sequences.go
+++ b/key_sequences.go
@@ -117,3 +117,15 @@ func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) {
 
 	return true, inputLen, KeyMsg(k)
 }
+
+// detectReportFocus detects a focus report sequence.
+// nolint: gomnd
+func detectReportFocus(input []byte) (hasRF bool, width int, msg Msg) {
+	switch {
+	case bytes.Equal(input, []byte("\x1b[I")):
+		return true, 3, FocusMsg{}
+	case bytes.Equal(input, []byte("\x1b[O")):
+		return true, 3, BlurMsg{}
+	}
+	return false, 0, nil
+}
diff --git a/nil_renderer.go b/nil_renderer.go
index f4a83b6..0bc4a17 100644
--- a/nil_renderer.go
+++ b/nil_renderer.go
@@ -23,3 +23,6 @@ func (n nilRenderer) enableMouseSGRMode()        {}
 func (n nilRenderer) disableMouseSGRMode()       {}
 func (n nilRenderer) bracketedPasteActive() bool { return false }
 func (n nilRenderer) setWindowTitle(_ string)    {}
+func (n nilRenderer) reportFocus() bool          { return false }
+func (n nilRenderer) enableReportFocus()         {}
+func (n nilRenderer) disableReportFocus()        {}
diff --git a/options.go b/options.go
index a810fe1..4a56fc8 100644
--- a/options.go
+++ b/options.go
@@ -234,3 +234,12 @@ func WithFPS(fps int) ProgramOption {
 		p.fps = fps
 	}
 }
+
+// WithReportFocus enables reporting when the terminal gains and lost focus.
+//
+// You can then check for FocusMsg and BlurMsg in your model's Update method.
+func WithReportFocus() ProgramOption {
+	return func(p *Program) {
+		p.startupOptions |= withReportFocus
+	}
+}
diff --git a/renderer.go b/renderer.go
index de3936e..9eb7943 100644
--- a/renderer.go
+++ b/renderer.go
@@ -70,6 +70,15 @@ type renderer interface {
 
 	// setWindowTitle sets the terminal window title.
 	setWindowTitle(string)
+
+	// reportFocus returns whether reporting focus events is enabled.
+	reportFocus() bool
+
+	// enableReportFocus reports focus events to the program.
+	enableReportFocus()
+
+	// disableReportFocus stops reporting focus events to the program.
+	disableReportFocus()
 }
 
 // repaintMsg forces a full repaint.
diff --git a/screen.go b/screen.go
index 6256c15..dfec48f 100644
--- a/screen.go
+++ b/screen.go
@@ -144,6 +144,26 @@ func DisableBracketedPaste() Msg {
 // disableBracketedPasteMsg with DisableBracketedPaste.
 type disableBracketedPasteMsg struct{}
 
+// enableReportFocusMsg is an internal message that signals to enable focus
+// reporting. You can send an enableReportFocusMsg with EnableReportFocus.
+type enableReportFocusMsg struct{}
+
+// EnableReportFocus is a special command that tells the Bubble Tea program to
+// report focus events to the program.
+func EnableReportFocus() Msg {
+	return enableReportFocusMsg{}
+}
+
+// disableReportFocusMsg is an internal message that signals to disable focus
+// reporting. You can send an disableReportFocusMsg with DisableReportFocus.
+type disableReportFocusMsg struct{}
+
+// DisableReportFocus is a special command that tells the Bubble Tea program to
+// stop reporting focus events to the program.
+func DisableReportFocus() Msg {
+	return disableReportFocusMsg{}
+}
+
 // EnterAltScreen enters the alternate screen buffer, which consumes the entire
 // terminal window. ExitAltScreen will return the terminal to its former state.
 //
diff --git a/standard_renderer.go b/standard_renderer.go
index f81920d..59448e1 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -47,6 +47,9 @@ type standardRenderer struct {
 	// whether or not we're currently using bracketed paste
 	bpActive bool
 
+	// reportingFocus whether reporting focus events is enabled
+	reportingFocus bool
+
 	// renderer dimensions; usually the size of the window
 	width  int
 	height int
@@ -458,6 +461,29 @@ func (r *standardRenderer) bracketedPasteActive() bool {
 	return r.bpActive
 }
 
+func (r *standardRenderer) enableReportFocus() {
+	r.mtx.Lock()
+	defer r.mtx.Unlock()
+
+	r.execute(ansi.EnableReportFocus)
+	r.reportingFocus = true
+}
+
+func (r *standardRenderer) disableReportFocus() {
+	r.mtx.Lock()
+	defer r.mtx.Unlock()
+
+	r.execute(ansi.DisableReportFocus)
+	r.reportingFocus = false
+}
+
+func (r *standardRenderer) reportFocus() bool {
+	r.mtx.Lock()
+	defer r.mtx.Unlock()
+
+	return r.reportingFocus
+}
+
 // setWindowTitle sets the terminal window title.
 func (r *standardRenderer) setWindowTitle(title string) {
 	r.execute(ansi.SetWindowTitle(title))
diff --git a/tea.go b/tea.go
index 62cd641..5ed9707 100644
--- a/tea.go
+++ b/tea.go
@@ -97,6 +97,7 @@ const (
 	// feature is on by default.
 	withoutCatchPanics
 	withoutBracketedPaste
+	withReportFocus
 )
 
 // channelHandlers manages the series of channels returned by various processes.
@@ -167,6 +168,7 @@ type Program struct {
 	ignoreSignals      uint32
 
 	bpWasActive bool // was the bracketed paste mode active before releasing the terminal?
+	reportFocus bool // was focus reporting active before releasing the terminal?
 
 	filter func(Model, Msg) Msg
 
@@ -390,6 +392,12 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 			case disableBracketedPasteMsg:
 				p.renderer.disableBracketedPaste()
 
+			case enableReportFocusMsg:
+				p.renderer.enableReportFocus()
+
+			case disableReportFocusMsg:
+				p.renderer.disableReportFocus()
+
 			case execMsg:
 				// NB: this blocks.
 				p.exec(msg.cmd, msg.fn)
@@ -542,6 +550,9 @@ func (p *Program) Run() (Model, error) {
 		p.renderer.enableMouseAllMotion()
 		p.renderer.enableMouseSGRMode()
 	}
+	if p.startupOptions&withReportFocus != 0 {
+		p.renderer.enableReportFocus()
+	}
 
 	// Start the renderer.
 	p.renderer.start()
@@ -694,6 +705,7 @@ func (p *Program) ReleaseTerminal() error {
 		p.renderer.stop()
 		p.altScreenWasActive = p.renderer.altScreen()
 		p.bpWasActive = p.renderer.bracketedPasteActive()
+		p.reportFocus = p.renderer.reportFocus()
 	}
 
 	return p.restoreTerminalState()
@@ -723,6 +735,9 @@ func (p *Program) RestoreTerminal() error {
 	if p.bpWasActive {
 		p.renderer.enableBracketedPaste()
 	}
+	if p.reportFocus {
+		p.renderer.enableReportFocus()
+	}
 
 	// If the output is a terminal, it may have been resized while another
 	// process was at the foreground, in which case we may not have received
diff --git a/tty.go b/tty.go
index 88aacab..0250778 100644
--- a/tty.go
+++ b/tty.go
@@ -44,6 +44,10 @@ func (p *Program) restoreTerminalState() error {
 		p.renderer.showCursor()
 		p.disableMouse()
 
+		if p.renderer.reportFocus() {
+			p.renderer.disableReportFocus()
+		}
+
 		if p.renderer.altScreen() {
 			p.renderer.exitAltScreen()
 


Test patch:
diff --git a/key_test.go b/key_test.go
index 67b0c50..0970b98 100644
--- a/key_test.go
+++ b/key_test.go
@@ -134,6 +134,15 @@ func TestDetectOneMsg(t *testing.T) {
 	// Add tests for the inputs that detectOneMsg() can parse, but
 	// detectSequence() cannot.
 	td = append(td,
+		// focus/blur
+		seqTest{
+			[]byte{'\x1b', '[', 'I'},
+			FocusMsg{},
+		},
+		seqTest{
+			[]byte{'\x1b', '[', 'O'},
+			BlurMsg{},
+		},
 		// Mouse event.
 		seqTest{
 			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
@@ -242,7 +251,8 @@ func TestReadInput(t *testing.T) {
 		out     []Msg
 	}
 	testData := []test{
-		{"a",
+		{
+			"a",
 			[]byte{'a'},
 			[]Msg{
 				KeyMsg{
@@ -251,7 +261,8 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{" ",
+		{
+			" ",
 			[]byte{' '},
 			[]Msg{
 				KeyMsg{
@@ -260,14 +271,16 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"a alt+a",
+		{
+			"a alt+a",
 			[]byte{'a', '\x1b', 'a'},
 			[]Msg{
 				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
 				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true},
 			},
 		},
-		{"a alt+a a",
+		{
+			"a alt+a a",
 			[]byte{'a', '\x1b', 'a', 'a'},
 			[]Msg{
 				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
@@ -275,7 +288,8 @@ func TestReadInput(t *testing.T) {
 				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
 			},
 		},
-		{"ctrl+a",
+		{
+			"ctrl+a",
 			[]byte{byte(keySOH)},
 			[]Msg{
 				KeyMsg{
@@ -283,14 +297,16 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"ctrl+a ctrl+b",
+		{
+			"ctrl+a ctrl+b",
 			[]byte{byte(keySOH), byte(keySTX)},
 			[]Msg{
 				KeyMsg{Type: KeyCtrlA},
 				KeyMsg{Type: KeyCtrlB},
 			},
 		},
-		{"alt+a",
+		{
+			"alt+a",
 			[]byte{byte(0x1b), 'a'},
 			[]Msg{
 				KeyMsg{
@@ -300,7 +316,8 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"abcd",
+		{
+			"abcd",
 			[]byte{'a', 'b', 'c', 'd'},
 			[]Msg{
 				KeyMsg{
@@ -309,7 +326,8 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"up",
+		{
+			"up",
 			[]byte("\x1b[A"),
 			[]Msg{
 				KeyMsg{
@@ -317,7 +335,8 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"wheel up",
+		{
+			"wheel up",
 			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
 			[]Msg{
 				MouseMsg{
@@ -329,7 +348,8 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"left motion release",
+		{
+			"left motion release",
 			[]byte{
 				'\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33),
 				'\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33),
@@ -351,7 +371,8 @@ func TestReadInput(t *testing.T) {
 				}),
 			},
 		},
-		{"shift+tab",
+		{
+			"shift+tab",
 			[]byte{'\x1b', '[', 'Z'},
 			[]Msg{
 				KeyMsg{
@@ -359,11 +380,13 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"enter",
+		{
+			"enter",
 			[]byte{'\r'},
 			[]Msg{KeyMsg{Type: KeyEnter}},
 		},
-		{"alt+enter",
+		{
+			"alt+enter",
 			[]byte{'\x1b', '\r'},
 			[]Msg{
 				KeyMsg{
@@ -372,7 +395,8 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"insert",
+		{
+			"insert",
 			[]byte{'\x1b', '[', '2', '~'},
 			[]Msg{
 				KeyMsg{
@@ -380,7 +404,8 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"alt+ctrl+a",
+		{
+			"alt+ctrl+a",
 			[]byte{'\x1b', byte(keySOH)},
 			[]Msg{
 				KeyMsg{
@@ -389,52 +414,64 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"?CSI[45 45 45 45 88]?",
+		{
+			"?CSI[45 45 45 45 88]?",
 			[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
 			[]Msg{unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
 		},
 		// Powershell sequences.
-		{"up",
+		{
+			"up",
 			[]byte{'\x1b', 'O', 'A'},
 			[]Msg{KeyMsg{Type: KeyUp}},
 		},
-		{"down",
+		{
+			"down",
 			[]byte{'\x1b', 'O', 'B'},
 			[]Msg{KeyMsg{Type: KeyDown}},
 		},
-		{"right",
+		{
+			"right",
 			[]byte{'\x1b', 'O', 'C'},
 			[]Msg{KeyMsg{Type: KeyRight}},
 		},
-		{"left",
+		{
+			"left",
 			[]byte{'\x1b', 'O', 'D'},
 			[]Msg{KeyMsg{Type: KeyLeft}},
 		},
-		{"alt+enter",
+		{
+			"alt+enter",
 			[]byte{'\x1b', '\x0d'},
 			[]Msg{KeyMsg{Type: KeyEnter, Alt: true}},
 		},
-		{"alt+backspace",
+		{
+			"alt+backspace",
 			[]byte{'\x1b', '\x7f'},
 			[]Msg{KeyMsg{Type: KeyBackspace, Alt: true}},
 		},
-		{"ctrl+@",
+		{
+			"ctrl+@",
 			[]byte{'\x00'},
 			[]Msg{KeyMsg{Type: KeyCtrlAt}},
 		},
-		{"alt+ctrl+@",
+		{
+			"alt+ctrl+@",
 			[]byte{'\x1b', '\x00'},
 			[]Msg{KeyMsg{Type: KeyCtrlAt, Alt: true}},
 		},
-		{"esc",
+		{
+			"esc",
 			[]byte{'\x1b'},
 			[]Msg{KeyMsg{Type: KeyEsc}},
 		},
-		{"alt+esc",
+		{
+			"alt+esc",
 			[]byte{'\x1b', '\x1b'},
 			[]Msg{KeyMsg{Type: KeyEsc, Alt: true}},
 		},
-		{"[a b] o",
+		{
+			"[a b] o",
 			[]byte{
 				'\x1b', '[', '2', '0', '0', '~',
 				'a', ' ', 'b',
@@ -446,11 +483,13 @@ func TestReadInput(t *testing.T) {
 				KeyMsg{Type: KeyRunes, Runes: []rune("o")},
 			},
 		},
-		{"[a\x03\nb]",
+		{
+			"[a\x03\nb]",
 			[]byte{
 				'\x1b', '[', '2', '0', '0', '~',
 				'a', '\x03', '\n', 'b',
-				'\x1b', '[', '2', '0', '1', '~'},
+				'\x1b', '[', '2', '0', '1', '~',
+			},
 			[]Msg{
 				KeyMsg{Type: KeyRunes, Runes: []rune("a\x03\nb"), Paste: true},
 			},
@@ -460,11 +499,13 @@ func TestReadInput(t *testing.T) {
 		// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
 		// This is incorrect, but it makes our test fail if we try it out.
 		testData = append(testData,
-			test{"?0xfe?",
+			test{
+				"?0xfe?",
 				[]byte{'\xfe'},
 				[]Msg{unknownInputByteMsg(0xfe)},
 			},
-			test{"a ?0xfe?   b",
+			test{
+				"a ?0xfe?   b",
 				[]byte{'a', '\xfe', ' ', 'b'},
 				[]Msg{
 					KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},