chore(deps): migrate to charm v2 (#204)

Drew Smirnoff created

* chore(scope): migrate to charm v2

* test: upgrade tests to v2, correct html_test

Change summary

go.mod                    | 18 ++++++++-----
go.sum                    | 44 +++++++++++++++++++-------------
main.go                   | 12 +++++---
tui/choice.go             | 10 +++---
tui/composer.go           | 55 +++++++++++++++-------------------------
tui/composer_test.go      | 52 +++++++++++++++++++-------------------
tui/drafts.go             | 19 +++++++------
tui/email_view.go         | 31 ++++++++++++----------
tui/email_view_test.go    | 24 ++++++++--------
tui/filepicker.go         | 10 +++---
tui/inbox.go              | 19 +++++++------
tui/inbox_test.go         | 32 +++++++++++-----------
tui/login.go              | 23 ++++++++--------
tui/settings.go           | 16 +++++-----
tui/signature.go          | 20 +++++++-------
tui/styles.go             | 10 +++---
tui/trash_archive.go      | 16 +++++-----
tui/trash_archive_test.go | 12 ++++----
view/html.go              |  2 
view/html_test.go         | 42 ++++++++++++++++++++----------
20 files changed, 243 insertions(+), 224 deletions(-)

Detailed changes

go.mod 🔗

@@ -3,6 +3,8 @@ module github.com/floatpane/matcha
 go 1.26.0
 
 require (
+	charm.land/bubbletea/v2 v2.0.0
+	charm.land/lipgloss/v2 v2.0.0
 	github.com/PuerkitoBio/goquery v1.11.0
 	github.com/charmbracelet/bubbles v1.0.0
 	github.com/charmbracelet/bubbletea v1.3.10
@@ -16,25 +18,26 @@ require (
 )
 
 require (
-	al.essio.dev/pkg/shellescape v1.5.1 // indirect
+	charm.land/bubbles/v2 v2.0.0 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
-	github.com/charmbracelet/colorprofile v0.4.1 // indirect
+	github.com/charmbracelet/colorprofile v0.4.2 // indirect
+	github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
 	github.com/charmbracelet/x/ansi v0.11.6 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
-	github.com/clipperhouse/displaywidth v0.9.0 // indirect
-	github.com/clipperhouse/stringish v0.1.1 // indirect
-	github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
-	github.com/danieljoos/wincred v1.2.2 // indirect
+	github.com/charmbracelet/x/termios v0.1.1 // indirect
+	github.com/charmbracelet/x/windows v0.2.2 // indirect
+	github.com/clipperhouse/displaywidth v0.11.0 // indirect
+	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
 	github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
-	github.com/mattn/go-runewidth v0.0.19 // indirect
+	github.com/mattn/go-runewidth v0.0.20 // indirect
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/termenv v0.16.0 // indirect
@@ -43,4 +46,5 @@ require (
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	github.com/zalando/go-keyring v0.2.6 // indirect
 	golang.org/x/net v0.47.0 // indirect
+	golang.org/x/sync v0.19.0 // indirect
 )

go.sum 🔗

@@ -1,5 +1,9 @@
-al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
-al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
+charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
+charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
+charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
+charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
+charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
+charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
@@ -10,32 +14,34 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
-github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
+github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
-github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
-github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
+github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
 github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
 github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
 github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
 github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
-github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
-github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
-github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
-github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
-github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
-github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
-github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
-github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
-github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
 github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
 github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
 github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
@@ -60,8 +66,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
-github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
-github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
+github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -110,6 +116,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

main.go 🔗

@@ -19,7 +19,7 @@ import (
 	"sync"
 	"time"
 
-	tea "github.com/charmbracelet/bubbletea"
+	tea "charm.land/bubbletea/v2"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
 	"github.com/floatpane/matcha/sender"
@@ -112,7 +112,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.height = msg.Height
 		return m, nil
 
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		if msg.String() == "ctrl+c" {
 			return m, tea.Quit
 		}
@@ -856,8 +856,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *mainModel) View() string {
-	return m.current.View()
+func (m *mainModel) View() tea.View {
+	v := m.current.View()
+	v.AltScreen = true
+	return v
 }
 
 func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
@@ -1881,7 +1883,7 @@ func main() {
 		initialModel = newInitialModel(cfg)
 	}
 
-	p := tea.NewProgram(initialModel, tea.WithAltScreen())
+	p := tea.NewProgram(initialModel)
 
 	if _, err := p.Run(); err != nil {
 		fmt.Printf("Alas, there's been an error: %v", err)

tui/choice.go 🔗

@@ -5,8 +5,8 @@ import (
 	"reflect"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
 )
 
@@ -61,7 +61,7 @@ func (m Choice) Init() tea.Cmd {
 
 func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		switch msg.String() {
 		case "up", "k":
 			if m.cursor > 0 {
@@ -115,7 +115,7 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m Choice) View() string {
+func (m Choice) View() tea.View {
 	var b strings.Builder
 
 	b.WriteString(logoStyle.Render(choiceLogo))
@@ -147,5 +147,5 @@ func (m Choice) View() string {
 	b.WriteString("\n\n")
 	b.WriteString(helpStyle.Render("Use ↑/↓ to navigate, enter to select, and ctrl+c to quit."))
 
-	return docStyle.Render(b.String())
+	return tea.NewView(docStyle.Render(b.String()))
 }

tui/composer.go 🔗

@@ -4,10 +4,10 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/charmbracelet/bubbles/textarea"
-	"github.com/charmbracelet/bubbles/textinput"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/bubbles/v2/textarea"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
 	"github.com/google/uuid"
 )
@@ -22,7 +22,6 @@ var (
 var (
 	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
-	cursorStyle         = focusedStyle.Copy()
 	noStyle             = lipgloss.NewStyle()
 	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
 	focusedButton       = focusedStyle.Copy().Render("[ Send ]")
@@ -87,41 +86,34 @@ func NewComposer(from, to, subject, body string) *Composer {
 	}
 
 	m.toInput = textinput.New()
-	m.toInput.Cursor.Style = cursorStyle
 	m.toInput.Placeholder = "To"
 	m.toInput.SetValue(to)
 	m.toInput.Prompt = "> "
 	m.toInput.CharLimit = 256
 
 	m.ccInput = textinput.New()
-	m.ccInput.Cursor.Style = cursorStyle
 	m.ccInput.Placeholder = "Cc"
 	m.ccInput.Prompt = "> "
 	m.ccInput.CharLimit = 256
 
 	m.bccInput = textinput.New()
-	m.bccInput.Cursor.Style = cursorStyle
 	m.bccInput.Placeholder = "Bcc"
 	m.bccInput.Prompt = "> "
 	m.bccInput.CharLimit = 256
 
 	m.subjectInput = textinput.New()
-	m.subjectInput.Cursor.Style = cursorStyle
 	m.subjectInput.Placeholder = "Subject"
 	m.subjectInput.SetValue(subject)
 	m.subjectInput.Prompt = "> "
 	m.subjectInput.CharLimit = 256
 
 	m.bodyInput = textarea.New()
-	m.bodyInput.Cursor.Style = cursorStyle
 	m.bodyInput.Placeholder = "Body (Markdown supported)..."
 	m.bodyInput.SetValue(body)
 	m.bodyInput.Prompt = "> "
 	m.bodyInput.SetHeight(10)
-	m.bodyInput.SetCursor(0)
 
 	m.signatureInput = textarea.New()
-	m.signatureInput.Cursor.Style = cursorStyle
 	m.signatureInput.Placeholder = "Signature (optional)..."
 	m.signatureInput.Prompt = "> "
 	m.signatureInput.SetHeight(3)
@@ -153,7 +145,7 @@ func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string
 	return m
 }
 
-// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt.
+// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
 func (m *Composer) ResetConfirmation() {
 	m.confirmingExit = false
 }
@@ -189,22 +181,18 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.width = msg.Width
 		m.height = msg.Height
 		inputWidth := msg.Width - 6
-		m.toInput.Width = inputWidth
-		m.ccInput.Width = inputWidth
-		m.bccInput.Width = inputWidth
-		m.subjectInput.Width = inputWidth
+		m.toInput.SetWidth(inputWidth)
+		m.ccInput.SetWidth(inputWidth)
+		m.bccInput.SetWidth(inputWidth)
+		m.subjectInput.SetWidth(inputWidth)
 		m.bodyInput.SetWidth(inputWidth)
 		m.signatureInput.SetWidth(inputWidth)
 
-	case SetComposerCursorToStartMsg:
-		m.bodyInput.SetCursor(0)
-		return m, nil
-
 	case FileSelectedMsg:
 		m.attachmentPath = msg.Path
 		return m, nil
 
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		// Handle contact suggestions mode
 		if m.showSuggestions && len(m.suggestions) > 0 {
 			switch msg.String() {
@@ -236,7 +224,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, nil
 			}
 			// For shift+tab, close suggestions and let it fall through to normal handling
-			if msg.Type == tea.KeyShiftTab {
+			if msg.String() == "shift+tab" {
 				m.showSuggestions = false
 				m.suggestions = nil
 			}
@@ -273,15 +261,15 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 
-		switch msg.Type {
-		case tea.KeyCtrlC:
+		switch msg.String() {
+		case "ctrl+c":
 			return m, tea.Quit
-		case tea.KeyEsc:
+		case "esc":
 			m.confirmingExit = true
 			return m, nil
 
-		case tea.KeyTab, tea.KeyShiftTab:
-			if msg.Type == tea.KeyShiftTab {
+		case "tab", "shift+tab":
+			if msg.String() == "shift+tab" {
 				m.focusIndex--
 			} else {
 				m.focusIndex++
@@ -318,13 +306,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, m.subjectInput.Focus())
 			case focusBody:
 				cmds = append(cmds, m.bodyInput.Focus())
-				cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
 			case focusSignature:
 				cmds = append(cmds, m.signatureInput.Focus())
 			}
 			return m, tea.Batch(cmds...)
 
-		case tea.KeyEnter:
+		case "enter":
 			switch m.focusIndex {
 			case focusFrom:
 				if len(m.accounts) > 1 {
@@ -396,7 +383,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *Composer) View() string {
+func (m *Composer) View() tea.View {
 	var composerView strings.Builder
 	var button string
 
@@ -494,7 +481,7 @@ func (m *Composer) View() string {
 		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
 
 		dialog := DialogBoxStyle.Render(accountList.String())
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
 	}
 
 	if m.confirmingExit {
@@ -504,10 +491,10 @@ func (m *Composer) View() string {
 				HelpStyle.Render("\n(y/n)"),
 			),
 		)
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
 	}
 
-	return composerView.String()
+	return tea.NewView(composerView.String())
 }
 
 // SetAccounts sets the available accounts for sending.

tui/composer_test.go 🔗

@@ -3,7 +3,7 @@ package tui
 import (
 	"testing"
 
-	tea "github.com/charmbracelet/bubbletea"
+	tea "charm.land/bubbletea/v2"
 	"github.com/floatpane/matcha/config"
 )
 
@@ -23,49 +23,49 @@ func TestComposerUpdate(t *testing.T) {
 		}
 
 		// Simulate pressing Tab to move to the 'Cc' field.
-		model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusCc {
 			t.Errorf("After one Tab, focusIndex should be %d (focusCc), got %d", focusCc, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab to move to the 'Bcc' field.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusBcc {
 			t.Errorf("After two Tabs, focusIndex should be %d (focusBcc), got %d", focusBcc, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab to move to the 'Subject' field.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusSubject {
 			t.Errorf("After three Tabs, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Body' field.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusBody {
 			t.Errorf("After four Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Signature' field.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusSignature {
 			t.Errorf("After five Tabs, focusIndex should be %d (focusSignature), got %d", focusSignature, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Attachment' field.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusAttachment {
 			t.Errorf("After six Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Send' button.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusSend {
 			t.Errorf("After seven Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
@@ -73,7 +73,7 @@ func TestComposerUpdate(t *testing.T) {
 
 		// Simulate one more Tab to wrap around.
 		// With single account, From field is skipped, so it wraps to focusTo.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusTo {
 			t.Errorf("After eight Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
@@ -92,7 +92,7 @@ func TestComposerUpdate(t *testing.T) {
 		composer.focusIndex = focusSend
 
 		// Simulate pressing Enter to send the email.
-		_, cmd := composer.Update(tea.KeyMsg{Type: tea.KeyEnter})
+		_, cmd := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
 		if cmd == nil {
 			t.Fatal("Expected a command to be returned, but got nil.")
 		}
@@ -130,7 +130,7 @@ func TestComposerUpdate(t *testing.T) {
 		multiComposer.focusIndex = focusFrom
 
 		// Press Enter to open account picker
-		model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyEnter})
+		model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
 		multiComposer = model.(*Composer)
 
 		if !multiComposer.showAccountPicker {
@@ -138,7 +138,7 @@ func TestComposerUpdate(t *testing.T) {
 		}
 
 		// Navigate down to select second account
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyDown})
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
 		multiComposer = model.(*Composer)
 
 		if multiComposer.selectedAccountIdx != 1 {
@@ -146,7 +146,7 @@ func TestComposerUpdate(t *testing.T) {
 		}
 
 		// Press Enter to confirm selection
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyEnter})
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
 		multiComposer = model.(*Composer)
 
 		if multiComposer.showAccountPicker {
@@ -169,7 +169,7 @@ func TestComposerUpdate(t *testing.T) {
 		singleComposer.focusIndex = focusFrom
 
 		// Press Enter - should not open picker with single account
-		model, _ := singleComposer.Update(tea.KeyMsg{Type: tea.KeyEnter})
+		model, _ := singleComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
 		singleComposer = model.(*Composer)
 
 		if singleComposer.showAccountPicker {
@@ -190,23 +190,23 @@ func TestComposerUpdate(t *testing.T) {
 		}
 
 		// Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> Send -> From (wrap)
-		model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // To -> Cc
+		model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Cc -> Bcc
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Bcc -> Subject
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Bcc -> Subject
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Subject -> Body
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Subject -> Body
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Body -> Signature
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Body -> Signature
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Signature -> Attachment
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Signature -> Attachment
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Attachment -> Send
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> Send
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Send -> From (wrap)
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap)
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // From -> To (wrap)
+		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // From -> To (wrap)
 		multiComposer = model.(*Composer)
 
 		// With multiple accounts, From field should be included in tab order
@@ -257,9 +257,9 @@ func TestComposerGetFromAddress(t *testing.T) {
 // TestComposerSetSelectedAccount verifies account selection.
 func TestComposerSetSelectedAccount(t *testing.T) {
 	accounts := []config.Account{
-		{ID: "account-1", Email: "test1@example.com"},
-		{ID: "account-2", Email: "test2@example.com"},
-		{ID: "account-3", Email: "test3@example.com"},
+		{ID: "account-1", FetchEmail: "test1@example.com"},
+		{ID: "account-2", FetchEmail: "test2@example.com"},
+		{ID: "account-3", FetchEmail: "test3@example.com"},
 	}
 	composer := NewComposerWithAccounts(accounts, "account-1", "", "", "")
 

tui/drafts.go 🔗

@@ -5,10 +5,10 @@ import (
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/list"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/list"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
 )
 
@@ -116,7 +116,7 @@ func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.list.SetHeight(msg.Height - 4)
 		return m, nil
 
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		// Handle delete confirmation
 		if m.confirmDelete {
 			switch msg.String() {
@@ -183,7 +183,7 @@ func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m *Drafts) View() string {
+func (m *Drafts) View() tea.View {
 	var b strings.Builder
 
 	if m.confirmDelete {
@@ -193,18 +193,19 @@ func (m *Drafts) View() string {
 				HelpStyle.Render("\n(y/n)"),
 			),
 		)
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
 	}
 
 	if len(m.drafts) == 0 {
 		emptyMsg := lipgloss.NewStyle().
 			Foreground(lipgloss.Color("240")).
 			Render("No drafts saved.\n\nPress esc to go back.")
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg)
+		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg))
 	}
 
+	// list.View() still returns string in v2
 	b.WriteString(m.list.View())
-	return b.String()
+	return tea.NewView(b.String())
 }
 
 // SetDrafts updates the drafts list

tui/email_view.go 🔗

@@ -6,9 +6,9 @@ import (
 	"os"
 	"strings"
 
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/fetcher"
 	"github.com/floatpane/matcha/view"
 )
@@ -59,8 +59,10 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 	}
 
 	// Build viewport with initial size and set wrapped content.
-	vp := viewport.New(width, height-headerHeight-attachmentHeight)
-	wrapped := wrapBodyToWidth(body, vp.Width)
+	vp := viewport.New()
+	vp.SetWidth(width)
+	vp.SetHeight(height - headerHeight - attachmentHeight)
+	wrapped := wrapBodyToWidth(body, vp.Width())
 	vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
 
 	return &EmailView{
@@ -83,9 +85,9 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 
 	switch msg := msg.(type) {
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		// Handle 'esc' key locally
-		if msg.Type == tea.KeyEsc {
+		if msg.String() == "esc" {
 			if m.focusOnAttachments {
 				m.focusOnAttachments = false
 				return m, nil
@@ -136,7 +138,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					if err != nil {
 						body = fmt.Sprintf("Error rendering body: %v", err)
 					}
-					wrapped := wrapBodyToWidth(body, m.viewport.Width)
+					wrapped := wrapBodyToWidth(body, m.viewport.Width())
 					m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
 					return m, nil
 				}
@@ -178,8 +180,8 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			attachmentHeight = len(m.email.Attachments) + 2
 		}
 		// Update viewport dimensions
-		m.viewport.Width = msg.Width
-		m.viewport.Height = msg.Height - headerHeight - attachmentHeight
+		m.viewport.SetWidth(msg.Width)
+		m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
 
 		// When the window size changes, wrap and clear kitty images to keep placement stable
 		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
@@ -187,7 +189,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if err != nil {
 			body = fmt.Sprintf("Error rendering body: %v", err)
 		}
-		wrapped := wrapBodyToWidth(body, m.viewport.Width)
+		wrapped := wrapBodyToWidth(body, m.viewport.Width())
 		m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
 	}
 
@@ -197,14 +199,14 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *EmailView) View() string {
+func (m *EmailView) View() tea.View {
 	// Clear all Kitty graphics before rendering to prevent image stacking on scroll.
 	// This must be done synchronously via stdout before the frame is drawn,
 	// as escape sequences in the return string execute too late.
 	clearKittyGraphics()
 
 	header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject)
-	styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header)
+	styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
 
 	var help string
 	if m.focusOnAttachments {
@@ -234,7 +236,8 @@ func (m *EmailView) View() string {
 		attachmentView = attachmentBoxStyle.Render(b.String())
 	}
 
-	return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help)
+	// m.viewport.View() returns a string in Bubbles v2 viewport
+	return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
 }
 
 // GetAccountID returns the account ID for this email

tui/email_view_test.go 🔗

@@ -4,7 +4,7 @@ import (
 	"testing"
 	"time"
 
-	tea "github.com/charmbracelet/bubbletea"
+	tea "charm.land/bubbletea/v2"
 	"github.com/floatpane/matcha/fetcher"
 )
 
@@ -34,7 +34,7 @@ func TestEmailViewUpdate(t *testing.T) {
 		}
 
 		// Tab to focus on attachments
-		model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ := emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		emailView = model.(*EmailView)
 
 		if !emailView.focusOnAttachments {
@@ -42,7 +42,7 @@ func TestEmailViewUpdate(t *testing.T) {
 		}
 
 		// Tab back to body
-		model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		emailView = model.(*EmailView)
 		if emailView.focusOnAttachments {
 			t.Error("focusOnAttachments should be false after tabbing again")
@@ -55,7 +55,7 @@ func TestEmailViewUpdate(t *testing.T) {
 			t.Error("focusOnAttachments should be initially false")
 		}
 		// Tab
-		model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ := emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		emailView = model.(*EmailView)
 		if emailView.focusOnAttachments {
 			t.Error("focusOnAttachments should remain false when there are no attachments")
@@ -65,7 +65,7 @@ func TestEmailViewUpdate(t *testing.T) {
 	t.Run("Navigate attachments", func(t *testing.T) {
 		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
 		// Focus on attachments
-		model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ := emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		emailView = model.(*EmailView)
 
 		if emailView.attachmentCursor != 0 {
@@ -73,21 +73,21 @@ func TestEmailViewUpdate(t *testing.T) {
 		}
 
 		// Move down
-		model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+		model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyDown})
 		emailView = model.(*EmailView)
 		if emailView.attachmentCursor != 1 {
 			t.Errorf("After one down arrow, attachmentCursor should be 1, got %d", emailView.attachmentCursor)
 		}
 
 		// Move down again (should not go past the end)
-		model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+		model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyDown})
 		emailView = model.(*EmailView)
 		if emailView.attachmentCursor != 1 {
 			t.Errorf("attachmentCursor should not go past the end of the list, got %d", emailView.attachmentCursor)
 		}
 
 		// Move up
-		model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyUp})
+		model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyUp})
 		emailView = model.(*EmailView)
 		if emailView.attachmentCursor != 0 {
 			t.Errorf("After one up arrow, attachmentCursor should be 0, got %d", emailView.attachmentCursor)
@@ -97,15 +97,15 @@ func TestEmailViewUpdate(t *testing.T) {
 	t.Run("Download attachment", func(t *testing.T) {
 		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
 		// Focus on attachments
-		model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+		model, _ := emailView.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 		emailView = model.(*EmailView)
 
 		// Move to the second attachment
-		model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+		model, _ = emailView.Update(tea.KeyPressMsg{Code: tea.KeyDown})
 		emailView = model.(*EmailView)
 
 		// Press enter
-		_, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyEnter})
+		_, cmd := emailView.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
 		if cmd == nil {
 			t.Fatal("Expected a command, but got nil")
 		}
@@ -126,7 +126,7 @@ func TestEmailViewUpdate(t *testing.T) {
 	t.Run("Reply to email", func(t *testing.T) {
 		emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false)
 
-		_, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
+		_, cmd := emailView.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
 		if cmd == nil {
 			t.Fatal("Expected a command, but got nil")
 		}

tui/filepicker.go 🔗

@@ -7,8 +7,8 @@ import (
 	"path/filepath"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 )
 
 var (
@@ -52,7 +52,7 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.width = msg.Width
 		m.height = msg.Height
 
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		switch msg.String() {
 		case "up", "k":
 			if m.cursor > 0 {
@@ -92,7 +92,7 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *FilePicker) View() string {
+func (m *FilePicker) View() tea.View {
 	var b strings.Builder
 
 	b.WriteString(titleStyle.Render("Select a File") + "\n")
@@ -121,5 +121,5 @@ func (m *FilePicker) View() string {
 
 	b.WriteString("\n" + helpStyle.Render("↑/↓: navigate • enter: select • backspace: up • esc: cancel"))
 
-	return docStyle.Render(b.String())
+	return tea.NewView(docStyle.Render(b.String()))
 }

tui/inbox.go 🔗

@@ -5,17 +5,18 @@ import (
 	"io"
 	"strings"
 
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/list"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/list"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
 )
 
 var (
-	paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
-	inboxHelpStyle  = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
+	// In bubbles v2, list.DefaultStyles() takes a boolean for hasDarkBackground
+	paginationStyle = list.DefaultStyles(true).PaginationStyle.PaddingLeft(4)
+	inboxHelpStyle  = list.DefaultStyles(true).HelpStyle.PaddingLeft(4).PaddingBottom(1)
 	tabStyle        = lipgloss.NewStyle().Padding(0, 2)
 	activeTabStyle  = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
 	tabBarStyle     = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
@@ -299,7 +300,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 
 	switch msg := msg.(type) {
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		if m.list.FilterState() == list.Filtering {
 			break
 		}
@@ -490,7 +491,7 @@ func (m *Inbox) fetchMoreCmds() []tea.Cmd {
 	return cmds
 }
 
-func (m *Inbox) View() string {
+func (m *Inbox) View() tea.View {
 	var b strings.Builder
 
 	// Render tabs if there are multiple accounts
@@ -564,7 +565,7 @@ func (m *Inbox) View() string {
 
 	b.WriteString(helpView)
 
-	return b.String()
+	return tea.NewView(b.String())
 }
 
 // GetCurrentAccountID returns the currently selected account ID

tui/inbox_test.go 🔗

@@ -4,7 +4,7 @@ import (
 	"testing"
 	"time"
 
-	tea "github.com/charmbracelet/bubbletea"
+	tea "charm.land/bubbletea/v2"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
 )
@@ -14,21 +14,21 @@ func collectMsgs(cmd tea.Cmd) []tea.Msg {
 		return nil
 	}
 	msg := cmd()
-	switch batch := msg.(type) {
-	case tea.BatchMsg:
+	if msg == nil {
+		return nil
+	}
+
+	// Try type assertion to see if it's a BatchMsg
+	if batch, ok := msg.(tea.BatchMsg); ok {
 		var msgs []tea.Msg
 		for _, m := range batch {
-			if m != nil {
-				msgs = append(msgs, m)
-			}
+			msgs = append(msgs, collectMsgs(m)...)
 		}
 		return msgs
-	default:
-		if msg != nil {
-			return []tea.Msg{msg}
-		}
 	}
-	return nil
+
+	// Otherwise it's a regular message
+	return []tea.Msg{msg}
 }
 
 // TestInboxUpdate verifies the state transitions in the inbox view.
@@ -51,10 +51,10 @@ func TestInboxUpdate(t *testing.T) {
 	t.Run("Select email to view", func(t *testing.T) {
 		// By default, the first item is selected (index 0).
 		// Move down to the second item (index 1).
-		inbox.list, _ = inbox.list.Update(tea.KeyMsg{Type: tea.KeyDown})
+		inbox.list, _ = inbox.list.Update(tea.KeyPressMsg{Code: tea.KeyDown})
 
 		// Simulate pressing Enter to view the selected email.
-		_, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyEnter})
+		_, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
 		if cmd == nil {
 			t.Fatal("Expected a command, but got nil.")
 		}
@@ -157,7 +157,7 @@ func TestInboxDeleteEmailMsg(t *testing.T) {
 	inbox := NewInbox(emails, accounts)
 
 	// Simulate pressing 'd' to delete
-	_, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+	_, cmd := inbox.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
 	if cmd == nil {
 		t.Fatal("Expected a command, but got nil.")
 	}
@@ -190,7 +190,7 @@ func TestInboxArchiveEmailMsg(t *testing.T) {
 	inbox := NewInbox(emails, accounts)
 
 	// Simulate pressing 'a' to archive
-	_, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+	_, cmd := inbox.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})
 	if cmd == nil {
 		t.Fatal("Expected a command, but got nil.")
 	}
@@ -283,7 +283,7 @@ func TestFetchMoreTriggeredAtListEnd(t *testing.T) {
 
 	inbox := NewInbox(emails, accounts)
 
-	_, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyDown})
+	_, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyDown})
 	msgs := collectMsgs(cmd)
 
 	var fetchMsg FetchMoreEmailsMsg

tui/login.go 🔗

@@ -3,9 +3,9 @@ package tui
 import (
 	"strconv"
 
-	"github.com/charmbracelet/bubbles/textinput"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 )
 
 // Login holds the state for the login/add account form.
@@ -41,7 +41,6 @@ func NewLogin() *Login {
 	var t textinput.Model
 	for i := range m.inputs {
 		t = textinput.New()
-		t.Cursor.Style = focusedStyle
 		t.CharLimit = 128
 
 		switch i {
@@ -93,15 +92,15 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.width = msg.Width
 		m.height = msg.Height
 		for i := range m.inputs {
-			m.inputs[i].Width = msg.Width - 6
+			m.inputs[i].SetWidth(msg.Width - 6)
 		}
 
-	case tea.KeyMsg:
-		switch msg.Type {
-		case tea.KeyEsc:
+	case tea.KeyPressMsg:
+		switch msg.String() {
+		case "esc":
 			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
 
-		case tea.KeyEnter:
+		case "enter":
 			// Check if provider is "custom" to show/hide custom fields
 			provider := m.inputs[inputProvider].Value()
 			if provider == "custom" {
@@ -146,7 +145,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			fallthrough
 
-		case tea.KeyTab, tea.KeyShiftTab, tea.KeyUp, tea.KeyDown:
+		case "tab", "shift+tab", "up", "down":
 			s := msg.String()
 
 			// Check provider to update showCustom
@@ -205,7 +204,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View renders the login form.
-func (m *Login) View() string {
+func (m *Login) View() tea.View {
 	title := "Add Account"
 	if m.isEditMode {
 		title = "Edit Account"
@@ -240,7 +239,7 @@ func (m *Login) View() string {
 
 	views = append(views, helpStyle.Render("\nenter: save • tab: next field • esc: back to menu"))
 
-	return lipgloss.JoinVertical(lipgloss.Left, views...)
+	return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, views...))
 }
 
 // SetEditMode sets the login form to edit an existing account.

tui/settings.go 🔗

@@ -4,8 +4,8 @@ import (
 	"fmt"
 	"strings"
 
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
 )
 
@@ -58,7 +58,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.height = msg.Height
 		return m, nil
 
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		if m.state == SettingsMain {
 			return m.updateMain(msg)
 		} else {
@@ -68,7 +68,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *Settings) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 	switch msg.String() {
 	case "up", "k":
 		if m.cursor > 0 {
@@ -99,7 +99,7 @@ func (m *Settings) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *Settings) updateAccounts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 	if m.confirmingDelete {
 		switch msg.String() {
 		case "y", "Y":
@@ -146,11 +146,11 @@ func (m *Settings) updateAccounts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
 }
 
 // View renders the settings screen.
-func (m *Settings) View() string {
+func (m *Settings) View() tea.View {
 	if m.state == SettingsMain {
-		return m.viewMain()
+		return tea.NewView(m.viewMain())
 	}
-	return m.viewAccounts()
+	return tea.NewView(m.viewAccounts())
 }
 
 func (m *Settings) viewMain() string {

tui/signature.go 🔗

@@ -1,9 +1,9 @@
 package tui
 
 import (
-	"github.com/charmbracelet/bubbles/textarea"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/bubbles/v2/textarea"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
 )
 
@@ -48,11 +48,11 @@ func (m *SignatureEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.textarea.SetHeight(msg.Height - 10)
 		return m, nil
 
-	case tea.KeyMsg:
-		switch msg.Type {
-		case tea.KeyCtrlC:
+	case tea.KeyPressMsg:
+		switch msg.String() {
+		case "ctrl+c":
 			return m, tea.Quit
-		case tea.KeyEsc:
+		case "esc":
 			// Save and go back to settings
 			signature := m.textarea.Value()
 			go config.SaveSignature(signature)
@@ -65,16 +65,16 @@ func (m *SignatureEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View renders the signature editor screen.
-func (m *SignatureEditor) View() string {
+func (m *SignatureEditor) View() tea.View {
 	title := titleStyle.Render("Email Signature")
 	hint := accountEmailStyle.Render("This signature will be appended to your emails.")
 
-	return lipgloss.JoinVertical(lipgloss.Left,
+	return tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
 		title,
 		hint,
 		"",
 		m.textarea.View(),
 		"",
 		helpStyle.Render("esc: save & back"),
-	)
+	))
 }

tui/styles.go 🔗

@@ -3,9 +3,9 @@ package tui
 import (
 	"fmt"
 
-	"github.com/charmbracelet/bubbles/spinner"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/bubbles/v2/spinner"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 )
 
 // ASCII logo for Matcha displayed during loading screens
@@ -68,11 +68,11 @@ func (m Status) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m Status) View() string {
+func (m Status) View() tea.View {
 	logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 	styledLogo := logoStyle.Render(asciiLogo)
 
 	spinnerLine := fmt.Sprintf("   %s %s", m.spinner.View(), m.message)
 
-	return fmt.Sprintf("%s\n%s\n\n", styledLogo, spinnerLine)
+	return tea.NewView(fmt.Sprintf("%s\n%s\n\n", styledLogo, spinnerLine))
 }

tui/trash_archive.go 🔗

@@ -3,9 +3,9 @@ package tui
 import (
 	"strings"
 
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
 )
@@ -42,7 +42,7 @@ func (m *TrashArchive) Init() tea.Cmd {
 
 func (m *TrashArchive) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		switch msg.String() {
 		case "tab":
 			// Toggle between trash and archive
@@ -81,7 +81,7 @@ func (m *TrashArchive) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m *TrashArchive) View() string {
+func (m *TrashArchive) View() tea.View {
 	var b strings.Builder
 
 	// Render the mailbox toggle tabs
@@ -104,12 +104,12 @@ func (m *TrashArchive) View() string {
 
 	// Render the active inbox
 	if m.activeView == MailboxTrash {
-		b.WriteString(m.trashInbox.View())
+		b.WriteString(m.trashInbox.View().Content)
 	} else {
-		b.WriteString(m.archiveInbox.View())
+		b.WriteString(m.archiveInbox.View().Content)
 	}
 
-	return b.String()
+	return tea.NewView(b.String())
 }
 
 // GetActiveMailbox returns the currently active mailbox kind

tui/trash_archive_test.go 🔗

@@ -4,7 +4,7 @@ import (
 	"testing"
 	"time"
 
-	tea "github.com/charmbracelet/bubbletea"
+	tea "charm.land/bubbletea/v2"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
 )
@@ -63,7 +63,7 @@ func TestTrashArchiveToggle(t *testing.T) {
 	}
 
 	// Press tab to switch to Archive
-	ta.Update(tea.KeyMsg{Type: tea.KeyTab})
+	ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 
 	if ta.activeView != MailboxArchive {
 		t.Errorf("Expected view to be MailboxArchive after tab, got %v", ta.activeView)
@@ -74,7 +74,7 @@ func TestTrashArchiveToggle(t *testing.T) {
 	}
 
 	// Press tab again to switch back to Trash
-	ta.Update(tea.KeyMsg{Type: tea.KeyTab})
+	ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 
 	if ta.activeView != MailboxTrash {
 		t.Errorf("Expected view to be MailboxTrash after second tab, got %v", ta.activeView)
@@ -180,7 +180,7 @@ func TestTrashArchiveViewEmailMsg(t *testing.T) {
 	ta := NewTrashArchive(trashEmails, nil, accounts)
 
 	// Simulate pressing Enter on trash view
-	_, cmd := ta.Update(tea.KeyMsg{Type: tea.KeyEnter})
+	_, cmd := ta.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
 	if cmd == nil {
 		t.Fatal("Expected a command, but got nil")
 	}
@@ -213,10 +213,10 @@ func TestTrashArchiveDeleteEmailMsg(t *testing.T) {
 	ta := NewTrashArchive(nil, archiveEmails, accounts)
 
 	// Switch to archive view
-	ta.Update(tea.KeyMsg{Type: tea.KeyTab})
+	ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 
 	// Simulate pressing 'd' to delete
-	_, cmd := ta.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+	_, cmd := ta.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
 	if cmd == nil {
 		t.Fatal("Expected a command, but got nil")
 	}

view/html.go 🔗

@@ -17,8 +17,8 @@ import (
 	_ "image/gif"
 	_ "image/jpeg"
 
+	"charm.land/lipgloss/v2"
 	"github.com/PuerkitoBio/goquery"
-	"github.com/charmbracelet/lipgloss"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/renderer/html"
 	"golang.org/x/sys/unix"

view/html_test.go 🔗

@@ -2,10 +2,11 @@ package view
 
 import (
 	"os"
+	"regexp"
 	"strings"
 	"testing"
 
-	"github.com/charmbracelet/lipgloss"
+	"charm.land/lipgloss/v2"
 )
 
 // clearAllTerminalEnv clears all environment variables that could indicate terminal capabilities
@@ -478,7 +479,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
 			},
 			input:               `<a href="http://example.com">Click here</a>`,
 			expectedContains:    "Click here",
-			expectedNotContains: "&lt;http://example.com&gt;",
+			expectedNotContains: "<http://example.com>",
 		},
 		{
 			name: "Link without hyperlink support",
@@ -497,7 +498,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
 			},
 			input:               `<img src="http://example.com/img.png" alt="alt text">`,
 			expectedContains:    "[Click here to view image: alt text]",
-			expectedNotContains: "&lt;http://example.com/img.png&gt;",
+			expectedNotContains: "<http://example.com/img.png>",
 		},
 		{
 			name: "Image link without hyperlink support",
@@ -509,6 +510,9 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
 		},
 	}
 
+	// Regex to strip out ANSI SGR escape codes (e.g. \x1b[38;2;...m)
+	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 			tc.setupHyperlinks()
@@ -518,12 +522,14 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}
 
-			if !strings.Contains(processed, tc.expectedContains) {
-				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expectedContains)
+			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
+
+			if !strings.Contains(cleanProcessed, tc.expectedContains) {
+				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
 			}
 
-			if tc.expectedNotContains != "" && strings.Contains(processed, tc.expectedNotContains) {
-				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", processed, tc.expectedNotContains)
+			if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
+				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
 			}
 		})
 	}
@@ -630,6 +636,8 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
 		},
 	}
 
+	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 			tc.clearAllImageEnv()
@@ -640,12 +648,14 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}
 
-			if !strings.Contains(processed, tc.expectedContains) {
-				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expectedContains)
+			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
+
+			if !strings.Contains(cleanProcessed, tc.expectedContains) {
+				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
 			}
 
-			if tc.expectedNotContains != "" && strings.Contains(processed, tc.expectedNotContains) {
-				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", processed, tc.expectedNotContains)
+			if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
+				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
 			}
 		})
 	}
@@ -683,15 +693,19 @@ func TestProcessBody(t *testing.T) {
 		},
 	}
 
+	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
 			if err != nil {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}
-			// Use Contains because styles add ANSI codes
-			if !strings.Contains(processed, tc.expected) {
-				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expected)
+
+			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
+
+			if !strings.Contains(cleanProcessed, tc.expected) {
+				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expected)
 			}
 		})
 	}