refactor: use uv bubbletea

Ayman Bagabas created

Change summary

go.mod                                                     | 18 ++
go.sum                                                     | 12 -
internal/tui/components/anim/anim.go                       |  4 
internal/tui/components/chat/chat.go                       | 10 -
internal/tui/components/chat/editor/editor.go              | 16 +-
internal/tui/components/chat/header/header.go              |  6 
internal/tui/components/chat/messages/messages.go          | 18 +-
internal/tui/components/chat/messages/renderer.go          |  2 
internal/tui/components/chat/messages/tool.go              |  8 
internal/tui/components/chat/sidebar/sidebar.go            |  6 
internal/tui/components/completions/completions.go         |  9 -
internal/tui/components/completions/item.go                |  4 
internal/tui/components/core/layout/container.go           | 14 +
internal/tui/components/core/layout/split.go               | 43 ++++---
internal/tui/components/core/list/list.go                  | 22 ++-
internal/tui/components/core/status/status.go              |  4 
internal/tui/components/dialogs/commands/arguments.go      | 20 +-
internal/tui/components/dialogs/commands/commands.go       | 22 ++-
internal/tui/components/dialogs/commands/item.go           |  4 
internal/tui/components/dialogs/compact/compact.go         |  4 
internal/tui/components/dialogs/dialogs.go                 |  9 
internal/tui/components/dialogs/filepicker/filepicker.go   |  4 
internal/tui/components/dialogs/init/init.go               |  4 
internal/tui/components/dialogs/models/models.go           | 20 ++-
internal/tui/components/dialogs/permissions/permissions.go |  4 
internal/tui/components/dialogs/quit/quit.go               |  6 
internal/tui/components/dialogs/sessions/sessions.go       | 20 ++-
internal/tui/components/logs/details.go                    |  4 
internal/tui/components/logs/table.go                      |  4 
internal/tui/page/chat/chat.go                             | 19 ++-
internal/tui/page/logs/logs.go                             | 14 +-
internal/tui/tui.go                                        | 28 +++-
internal/tui/util/util.go                                  |  6 
33 files changed, 215 insertions(+), 173 deletions(-)

Detailed changes

go.mod 🔗

@@ -1,6 +1,12 @@
 module github.com/charmbracelet/crush
 
-go 1.24.0
+go 1.24.3
+
+replace github.com/charmbracelet/bubbletea/v2 => ../bubbletea
+
+replace github.com/charmbracelet/lipgloss/v2 => ../lipgloss
+
+replace github.com/charmbracelet/uv => ../uv
 
 require (
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
@@ -17,7 +23,7 @@ require (
 	github.com/charmbracelet/fang v0.1.0
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
-	github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
+	github.com/charmbracelet/x/ansi v0.9.3
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444
 	github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
@@ -41,7 +47,11 @@ require (
 )
 
 require (
-	golang.org/x/term v0.31.0 // indirect
+	github.com/charmbracelet/uv v0.0.0-00010101000000-000000000000 // indirect
+	github.com/charmbracelet/x/termios v0.1.1 // indirect
+)
+
+require (
 	cloud.google.com/go v0.116.0 // indirect
 	cloud.google.com/go/auth v0.13.0 // indirect
 	cloud.google.com/go/compute/metadata v0.6.0 // indirect
@@ -69,7 +79,6 @@ require (
 	github.com/charmbracelet/colorprofile v0.3.1 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
 	github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
-	github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
@@ -132,6 +141,7 @@ require (
 	golang.org/x/net v0.39.0 // indirect
 	golang.org/x/sync v0.13.0 // indirect
 	golang.org/x/sys v0.32.0 // indirect
+	golang.org/x/term v0.31.0 // indirect
 	golang.org/x/text v0.24.0 // indirect
 	google.golang.org/genai v1.3.0
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect

go.sum 🔗

@@ -70,18 +70,14 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
 github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94 h1:QIi50k+uNTJmp2sMs+33D1m/EWr/7OPTJ8x92AY3eOc=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
 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/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg=
 github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
-github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
-github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+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.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
@@ -90,10 +86,10 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHE
 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-20250611152503-f53cdd7e01ef h1:v7qwsZ2OxzlwvpKwz8dtZXp7fIJlcDEUOyFBNE4fz4Q=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
-github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
-github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
 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/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.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
 github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=

internal/tui/components/anim/anim.go 🔗

@@ -257,7 +257,7 @@ func (a *anim) updateChars(chars *[]cyclingChar) {
 }
 
 // View renders the animation.
-func (a anim) View() tea.View {
+func (a anim) View() string {
 	var (
 		t = styles.CurrentTheme()
 		b strings.Builder
@@ -289,7 +289,7 @@ func (a anim) View() tea.View {
 		b.WriteString(textStyle.Render(a.ellipsis.View()))
 	}
 
-	return tea.NewView(b.String())
+	return b.String()
 }
 
 func GetColor(c color.Color) string {

internal/tui/components/chat/chat.go 🔗

@@ -101,12 +101,10 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View renders the message list or an initial screen if empty.
-func (m *messageListCmp) View() tea.View {
-	return tea.NewView(
-		lipgloss.JoinVertical(
-			lipgloss.Left,
-			m.listCmp.View().String(),
-		),
+func (m *messageListCmp) View() string {
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		m.listCmp.View(),
 	)
 }
 

internal/tui/components/chat/editor/editor.go 🔗

@@ -265,20 +265,22 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *editorCmp) View() tea.View {
-	t := styles.CurrentTheme()
+func (m *editorCmp) Cursor() *tea.Cursor {
 	cursor := m.textarea.Cursor()
 	if cursor != nil {
 		cursor.X = cursor.X + m.x + 1
 		cursor.Y = cursor.Y + m.y + 1 // adjust for padding
 	}
+	return cursor
+}
+
+func (m *editorCmp) View() string {
+	t := styles.CurrentTheme()
 	if len(m.attachments) == 0 {
 		content := t.S().Base.Padding(1).Render(
 			m.textarea.View(),
 		)
-		view := tea.NewView(content)
-		view.SetCursor(cursor)
-		return view
+		return content
 	}
 	content := t.S().Base.Padding(0, 1, 1, 1).Render(
 		lipgloss.JoinVertical(lipgloss.Top,
@@ -286,9 +288,7 @@ func (m *editorCmp) View() tea.View {
 			m.textarea.View(),
 		),
 	)
-	view := tea.NewView(content)
-	view.SetCursor(cursor)
-	return view
+	return content
 }
 
 func (m *editorCmp) SetSize(width, height int) tea.Cmd {

internal/tui/components/chat/header/header.go 🔗

@@ -58,9 +58,9 @@ func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return p, nil
 }
 
-func (p *header) View() tea.View {
+func (p *header) View() string {
 	if p.session.ID == "" {
-		return tea.NewView("")
+		return ""
 	}
 
 	t := styles.CurrentTheme()
@@ -87,7 +87,7 @@ func (p *header) View() tea.View {
 			parts...,
 		),
 	)
-	return tea.NewView(content)
+	return content
 }
 
 func (h *header) details() string {

internal/tui/components/chat/messages/messages.go 🔗

@@ -83,20 +83,20 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View renders the message component based on its current state.
 // Returns different views for spinning, user, and assistant messages.
-func (m *messageCmp) View() tea.View {
+func (m *messageCmp) View() string {
 	if m.spinning {
-		return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String()))
+		return m.style().PaddingLeft(1).Render(m.anim.View())
 	}
 	if m.message.ID != "" {
 		// this is a user or assistant message
 		switch m.message.Role {
 		case message.User:
-			return tea.NewView(m.renderUserMessage())
+			return m.renderUserMessage()
 		default:
-			return tea.NewView(m.renderAssistantMessage())
+			return m.renderAssistantMessage()
 		}
 	}
-	return tea.NewView(m.style().Render("No message content"))
+	return m.style().Render("No message content")
 }
 
 // GetMessage returns the underlying message data
@@ -283,7 +283,7 @@ func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *assistantSectionModel) View() tea.View {
+func (m *assistantSectionModel) View() string {
 	t := styles.CurrentTheme()
 	finishData := m.message.FinishPart()
 	finishTime := time.Unix(finishData.Time, 0)
@@ -292,10 +292,8 @@ func (m *assistantSectionModel) View() tea.View {
 	icon := t.S().Subtle.Render(styles.ModelIcon)
 	model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
 	assistant := fmt.Sprintf("%s %s %s", icon, model, infoMsg)
-	return tea.NewView(
-		t.S().Base.PaddingLeft(2).Render(
-			core.Section(assistant, m.width-2),
-		),
+	return t.S().Base.PaddingLeft(2).Render(
+		core.Section(assistant, m.width-2),
 	)
 }
 

internal/tui/components/chat/messages/renderer.go 🔗

@@ -542,7 +542,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
 
 	if v.result.ToolCallID == "" {
 		v.spinning = true
-		parts = append(parts, v.anim.View().String())
+		parts = append(parts, v.anim.View())
 	} else {
 		v.spinning = false
 	}

internal/tui/components/chat/messages/tool.go 🔗

@@ -133,19 +133,19 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View renders the tool call component based on its current state.
 // Shows either a pending animation or the tool-specific rendered result.
-func (m *toolCallCmp) View() tea.View {
+func (m *toolCallCmp) View() string {
 	box := m.style()
 
 	if !m.call.Finished && !m.cancelled {
-		return tea.NewView(box.Render(m.renderPending()))
+		return box.Render(m.renderPending())
 	}
 
 	r := registry.lookup(m.call.Name)
 
 	if m.isNested {
-		return tea.NewView(box.Render(r.Render(m)))
+		return box.Render(r.Render(m))
 	}
-	return tea.NewView(box.Render(r.Render(m)))
+	return box.Render(r.Render(m))
 }
 
 // State management methods

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -107,7 +107,7 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *sidebarCmp) View() tea.View {
+func (m *sidebarCmp) View() string {
 	t := styles.CurrentTheme()
 	parts := []string{}
 	if !m.compactMode {
@@ -139,9 +139,7 @@ func (m *sidebarCmp) View() tea.View {
 		m.mcpBlock(),
 	)
 
-	return tea.NewView(
-		lipgloss.JoinVertical(lipgloss.Left, parts...),
-	)
+	return lipgloss.JoinVertical(lipgloss.Left, parts...)
 }
 
 func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {

internal/tui/components/completions/completions.go 🔗

@@ -157,15 +157,12 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View implements Completions.
-func (c *completionsCmp) View() tea.View {
+func (c *completionsCmp) View() string {
 	if len(c.list.Items()) == 0 {
-		return tea.NewView(c.style().Render("No completions found"))
+		return c.style().Render("No completions found")
 	}
 
-	view := tea.NewView(
-		c.style().Render(c.list.View().String()),
-	)
-	return view
+	return c.style().Render(c.list.View())
 }
 
 func (c *completionsCmp) style() lipgloss.Style {

internal/tui/components/completions/item.go 🔗

@@ -75,7 +75,7 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View implements CommandItem.
-func (c *completionItemCmp) View() tea.View {
+func (c *completionItemCmp) View() string {
 	t := styles.CurrentTheme()
 
 	itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
@@ -135,7 +135,7 @@ func (c *completionItemCmp) View() tea.View {
 			parts...,
 		),
 	)
-	return tea.NewView(item)
+	return item
 }
 
 // Blur implements CommandItem.

internal/tui/components/core/layout/container.go 🔗

@@ -72,7 +72,14 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
-func (c *container) View() tea.View {
+func (c *container) Cursor() *tea.Cursor {
+	if cursor, ok := c.content.(util.Cursor); ok {
+		return cursor.Cursor()
+	}
+	return nil
+}
+
+func (c *container) View() string {
 	t := styles.CurrentTheme()
 	width := c.width
 	height := c.height
@@ -106,10 +113,7 @@ func (c *container) View() tea.View {
 		PaddingLeft(c.paddingLeft)
 
 	contentView := c.content.View()
-	view := tea.NewView(style.Render(contentView.String()))
-	cursor := contentView.Cursor()
-	view.SetCursor(cursor)
-	return view
+	return style.Render(contentView)
 }
 
 func (c *container) SetSize(width, height int) tea.Cmd {

internal/tui/components/core/layout/split.go 🔗

@@ -104,17 +104,34 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return s, tea.Batch(cmds...)
 }
 
-func (s *splitPaneLayout) View() tea.View {
+func (s *splitPaneLayout) Cursor() *tea.Cursor {
+	if s.bottomPanel != nil {
+		if c, ok := s.bottomPanel.(util.Cursor); ok {
+			return c.Cursor()
+		}
+	} else if s.rightPanel != nil {
+		if c, ok := s.rightPanel.(util.Cursor); ok {
+			return c.Cursor()
+		}
+	} else if s.leftPanel != nil {
+		if c, ok := s.leftPanel.(util.Cursor); ok {
+			return c.Cursor()
+		}
+	}
+	return nil
+}
+
+func (s *splitPaneLayout) View() string {
 	var topSection string
 
 	if s.leftPanel != nil && s.rightPanel != nil {
 		leftView := s.leftPanel.View()
 		rightView := s.rightPanel.View()
-		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
+		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
 	} else if s.leftPanel != nil {
-		topSection = s.leftPanel.View().String()
+		topSection = s.leftPanel.View()
 	} else if s.rightPanel != nil {
-		topSection = s.rightPanel.View().String()
+		topSection = s.rightPanel.View()
 	} else {
 		topSection = ""
 	}
@@ -123,32 +140,20 @@ func (s *splitPaneLayout) View() tea.View {
 
 	if s.bottomPanel != nil && topSection != "" {
 		bottomView := s.bottomPanel.View()
-		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
+		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
 	} else if s.bottomPanel != nil {
-		finalView = s.bottomPanel.View().String()
+		finalView = s.bottomPanel.View()
 	} else {
 		finalView = topSection
 	}
 
-	// TODO: think of a better way to handle multiple cursors
-	var cursor *tea.Cursor
-	if s.bottomPanel != nil {
-		cursor = s.bottomPanel.View().Cursor()
-	} else if s.rightPanel != nil {
-		cursor = s.rightPanel.View().Cursor()
-	} else if s.leftPanel != nil {
-		cursor = s.leftPanel.View().Cursor()
-	}
-
 	t := styles.CurrentTheme()
 
 	style := t.S().Base.
 		Width(s.width).
 		Height(s.height)
 
-	view := tea.NewView(style.Render(finalView))
-	view.SetCursor(cursor)
-	return view
+	return style.Render(finalView)
 }
 
 func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {

internal/tui/components/core/list/list.go 🔗

@@ -281,12 +281,20 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
+// Cursor returns the current cursor position in the input field.
+func (m *model) Cursor() *tea.Cursor {
+	if m.filterable && !m.hideFilterInput {
+		return m.input.Cursor()
+	}
+	return nil
+}
+
 // View renders the list to a string for display.
 // Returns empty string if the list has no dimensions.
 // Triggers re-rendering if needed before returning content.
-func (m *model) View() tea.View {
+func (m *model) View() string {
 	if m.viewState.height == 0 || m.viewState.width == 0 {
-		return tea.NewView("") // No content to display
+		return "" // No content to display
 	}
 	if m.renderState.needsRerender {
 		m.renderVisible()
@@ -304,11 +312,7 @@ func (m *model) View() tea.View {
 			content,
 		)
 	}
-	view := tea.NewView(content)
-	if m.filterable && !m.hideFilterInput {
-		view.SetCursor(m.input.Cursor())
-	}
-	return view
+	return content
 }
 
 // handleKeyPress processes keyboard input for list navigation.
@@ -834,7 +838,7 @@ func (m *model) rerenderItem(inx int) {
 func (m *model) getItemLines(item util.Model) []string {
 	var itemLines []string
 
-	itemLines = strings.Split(item.View().String(), "\n")
+	itemLines = strings.Split(item.View(), "\n")
 
 	if m.gapSize > 0 {
 		gap := make([]string, m.gapSize)
@@ -1262,7 +1266,7 @@ func (m *model) filterSection(sect section, search string) *section {
 
 	// Check if section header itself matches
 	if sect.header != nil {
-		headerText := strings.ToLower(sect.header.View().String())
+		headerText := strings.ToLower(sect.header.View())
 		if strings.Contains(headerText, search) {
 			hasHeaderMatch = true
 			// If header matches, include all items in the section

internal/tui/components/core/status/status.go 🔗

@@ -94,13 +94,13 @@ func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *statusCmp) View() tea.View {
+func (m *statusCmp) View() string {
 	t := styles.CurrentTheme()
 	status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
 	if m.info.Msg != "" {
 		status = m.infoMsg()
 	}
-	return tea.NewView(status)
+	return status
 }
 
 func (m *statusCmp) infoMsg() string {

internal/tui/components/dialogs/commands/arguments.go 🔗

@@ -139,7 +139,7 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) View() tea.View {
+func (c *commandArgumentsDialogCmp) View() string {
 	t := styles.CurrentTheme()
 	baseStyle := t.S().Base
 
@@ -188,19 +188,19 @@ func (c *commandArgumentsDialogCmp) View() tea.View {
 		elements...,
 	)
 
-	view := tea.NewView(
-		baseStyle.Padding(1, 1, 0, 1).
-			Border(lipgloss.RoundedBorder()).
-			BorderForeground(t.BorderFocus).
-			Width(c.width).
-			Render(content),
-	)
+	return baseStyle.Padding(1, 1, 0, 1).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus).
+		Width(c.width).
+		Render(content)
+}
+
+func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor {
 	cursor := c.inputs[c.focusIndex].Cursor()
 	if cursor != nil {
 		cursor = c.moveCursor(cursor)
 	}
-	view.SetCursor(cursor)
-	return view
+	return cursor
 }
 
 func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -143,23 +143,29 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return c, nil
 }
 
-func (c *commandDialogCmp) View() tea.View {
+func (c *commandDialogCmp) View() string {
 	t := styles.CurrentTheme()
-	listView := c.commandList.View()
+	listView := c.commandList
 	radio := c.commandTypeRadio()
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
 		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
-		listView.String(),
+		listView.View(),
 		"",
 		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
 	)
-	v := tea.NewView(c.style().Render(content))
-	if listView.Cursor() != nil {
-		c := c.moveCursor(listView.Cursor())
-		v.SetCursor(c)
+	return c.style().Render(content)
+}
+
+func (c *commandDialogCmp) Cursor() *tea.Cursor {
+	if cursor, ok := c.commandList.(util.Cursor); ok {
+		cursor := cursor.Cursor()
+		if cursor != nil {
+			cursor = c.moveCursor(cursor)
+		}
+		return cursor
 	}
-	return v
+	return nil
 }
 
 func (c *commandDialogCmp) commandTypeRadio() string {

internal/tui/components/dialogs/commands/item.go 🔗

@@ -35,12 +35,12 @@ func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *itemSectionModel) View() tea.View {
+func (m *itemSectionModel) View() string {
 	t := styles.CurrentTheme()
 	title := ansi.Truncate(m.title, m.width-2, "…")
 	style := t.S().Base.Padding(1, 1, 0, 1)
 	title = t.S().Muted.Render(title)
-	return tea.NewView(style.Render(core.Section(title, m.width-2)))
+	return style.Render(core.Section(title, m.width-2))
 }
 
 func (m *itemSectionModel) GetSize() (int, int) {

internal/tui/components/dialogs/compact/compact.go 🔗

@@ -242,8 +242,8 @@ func (c *compactDialogCmp) render() string {
 		Render(dialogContent)
 }
 
-func (c *compactDialogCmp) View() tea.View {
-	return tea.NewView(c.render())
+func (c *compactDialogCmp) View() string {
+	return c.render()
 }
 
 // SetSize sets the size of the component.

internal/tui/components/dialogs/dialogs.go 🔗

@@ -37,7 +37,7 @@ type DialogCmp interface {
 	Dialogs() []DialogModel
 	HasDialogs() bool
 	GetLayers() []*lipgloss.Layer
-	ActiveView() *tea.View
+	ActiveModel() util.Model
 	ActiveDialogID() DialogID
 }
 
@@ -132,12 +132,11 @@ func (d dialogCmp) Dialogs() []DialogModel {
 	return d.dialogs
 }
 
-func (d dialogCmp) ActiveView() *tea.View {
+func (d dialogCmp) ActiveModel() util.Model {
 	if len(d.dialogs) == 0 {
 		return nil
 	}
-	view := d.dialogs[len(d.dialogs)-1].View()
-	return &view
+	return d.dialogs[len(d.dialogs)-1]
 }
 
 func (d dialogCmp) ActiveDialogID() DialogID {
@@ -150,7 +149,7 @@ func (d dialogCmp) ActiveDialogID() DialogID {
 func (d dialogCmp) GetLayers() []*lipgloss.Layer {
 	layers := []*lipgloss.Layer{}
 	for _, dialog := range d.Dialogs() {
-		dialogView := dialog.View().String()
+		dialogView := dialog.View()
 		row, col := dialog.Position()
 		layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
 	}

internal/tui/components/dialogs/filepicker/filepicker.go 🔗

@@ -148,7 +148,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *model) View() tea.View {
+func (m *model) View() string {
 	t := styles.CurrentTheme()
 
 	content := lipgloss.JoinVertical(
@@ -158,7 +158,7 @@ func (m *model) View() tea.View {
 		m.filePicker.View(),
 		t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
 	)
-	return tea.NewView(m.style().Render(content))
+	return m.style().Render(content)
 }
 
 func (m *model) currentImage() string {

internal/tui/components/dialogs/init/init.go 🔗

@@ -147,8 +147,8 @@ func (m *initDialogCmp) render() string {
 }
 
 // View implements tea.Model.
-func (m *initDialogCmp) View() tea.View {
-	return tea.NewView(m.render())
+func (m *initDialogCmp) View() string {
+	return m.render()
 }
 
 // SetSize sets the size of the component.

internal/tui/components/dialogs/models/models.go 🔗

@@ -154,22 +154,28 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *modelDialogCmp) View() tea.View {
+func (m *modelDialogCmp) View() string {
 	t := styles.CurrentTheme()
 	listView := m.modelList.View()
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
 		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-4)),
-		listView.String(),
+		listView,
 		"",
 		t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
 	)
-	v := tea.NewView(m.style().Render(content))
-	if listView.Cursor() != nil {
-		c := m.moveCursor(listView.Cursor())
-		v.SetCursor(c)
+	return m.style().Render(content)
+}
+
+func (m *modelDialogCmp) Cursor() *tea.Cursor {
+	if cursor, ok := m.modelList.(util.Cursor); ok {
+		cursor := cursor.Cursor()
+		if cursor != nil {
+			cursor = m.moveCursor(cursor)
+			return cursor
+		}
 	}
-	return v
+	return nil
 }
 
 func (m *modelDialogCmp) style() lipgloss.Style {

internal/tui/components/dialogs/permissions/permissions.go 🔗

@@ -478,8 +478,8 @@ func (p *permissionDialogCmp) render() string {
 		)
 }
 
-func (p *permissionDialogCmp) View() tea.View {
-	return tea.NewView(p.render())
+func (p *permissionDialogCmp) View() string {
+	return p.render()
 }
 
 func (p *permissionDialogCmp) SetSize() tea.Cmd {

internal/tui/components/dialogs/quit/quit.go 🔗

@@ -65,7 +65,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // View renders the quit dialog with Yes/No buttons.
-func (q *quitDialogCmp) View() tea.View {
+func (q *quitDialogCmp) View() string {
 	t := styles.CurrentTheme()
 	baseStyle := t.S().Base
 	yesStyle := t.S().Text
@@ -100,9 +100,7 @@ func (q *quitDialogCmp) View() tea.View {
 		Border(lipgloss.RoundedBorder()).
 		BorderForeground(t.BorderFocus)
 
-	return tea.NewView(
-		quitDialogStyle.Render(content),
-	)
+	return quitDialogStyle.Render(content)
 }
 
 func (q *quitDialogCmp) Position() (int, int) {

internal/tui/components/dialogs/sessions/sessions.go 🔗

@@ -122,23 +122,29 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return s, nil
 }
 
-func (s *sessionDialogCmp) View() tea.View {
+func (s *sessionDialogCmp) View() string {
 	t := styles.CurrentTheme()
 	listView := s.sessionsList.View()
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
 		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)),
-		listView.String(),
+		listView,
 		"",
 		t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)),
 	)
 
-	v := tea.NewView(s.style().Render(content))
-	if listView.Cursor() != nil {
-		c := s.moveCursor(listView.Cursor())
-		v.SetCursor(c)
+	return s.style().Render(content)
+}
+
+func (s *sessionDialogCmp) Cursor() *tea.Cursor {
+	if cursor, ok := s.sessionsList.(util.Cursor); ok {
+		cursor := cursor.Cursor()
+		if cursor != nil {
+			cursor = s.moveCursor(cursor)
+		}
+		return cursor
 	}
-	return v
+	return nil
 }
 
 func (s *sessionDialogCmp) style() lipgloss.Style {

internal/tui/components/logs/details.go 🔗

@@ -145,7 +145,7 @@ func getRelativeTime(logTime time.Time) string {
 	}
 }
 
-func (i *detailCmp) View() tea.View {
+func (i *detailCmp) View() string {
 	t := styles.CurrentTheme()
 	style := t.S().Base.
 		BorderStyle(lipgloss.RoundedBorder()).
@@ -153,7 +153,7 @@ func (i *detailCmp) View() tea.View {
 		Width(i.width - 2).   // Adjust width for border
 		Height(i.height - 2). // Adjust height for border
 		Padding(1)
-	return tea.NewView(style.Render(i.viewport.View()))
+	return style.Render(i.viewport.View())
 }
 
 func (i *detailCmp) GetSize() (int, int) {

internal/tui/components/logs/table.go 🔗

@@ -74,7 +74,7 @@ func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return i, tea.Batch(cmds...)
 }
 
-func (i *tableCmp) View() tea.View {
+func (i *tableCmp) View() string {
 	t := styles.CurrentTheme()
 	defaultStyles := table.DefaultStyles()
 
@@ -97,7 +97,7 @@ func (i *tableCmp) View() tea.View {
 		Foreground(t.FgBase)
 
 	i.table.SetStyles(defaultStyles)
-	return tea.NewView(i.table.View())
+	return i.table.View()
 }
 
 func (i *tableCmp) GetSize() (int, int) {

internal/tui/page/chat/chat.go 🔗

@@ -306,7 +306,7 @@ func (p *chatPage) GetSize() (int, int) {
 	return p.layout.GetSize()
 }
 
-func (p *chatPage) View() tea.View {
+func (p *chatPage) View() string {
 	if !p.compactMode || p.session.ID == "" {
 		// If not in compact mode or there is no session, we don't show the header
 		return p.layout.View()
@@ -314,8 +314,8 @@ func (p *chatPage) View() tea.View {
 	layoutView := p.layout.View()
 	chatView := strings.Join(
 		[]string{
-			p.header.View().String(),
-			layoutView.String(),
+			p.header.View(),
+			layoutView,
 		}, "\n",
 	)
 	layers := []*lipgloss.Layer{
@@ -330,7 +330,7 @@ func (p *chatPage) View() tea.View {
 		details := style.Render(
 			lipgloss.JoinVertical(
 				lipgloss.Left,
-				p.compactSidebar.View().String(),
+				p.compactSidebar.View(),
 				version,
 			),
 		)
@@ -339,9 +339,14 @@ func (p *chatPage) View() tea.View {
 	canvas := lipgloss.NewCanvas(
 		layers...,
 	)
-	view := tea.NewView(canvas.Render())
-	view.SetCursor(layoutView.Cursor())
-	return view
+	return canvas.Render()
+}
+
+func (p *chatPage) Cursor() *tea.Cursor {
+	if v, ok := p.layout.(util.Cursor); ok {
+		return v.Cursor()
+	}
+	return nil
 }
 
 func (p *chatPage) Bindings() []key.Binding {

internal/tui/page/logs/logs.go 🔗

@@ -51,18 +51,16 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return p, tea.Batch(cmds...)
 }
 
-func (p *logsPage) View() tea.View {
+func (p *logsPage) View() string {
 	baseStyle := styles.CurrentTheme().S().Base
 	style := baseStyle.Width(p.width).Height(p.height).Padding(1)
 	title := core.Title("Logs", p.width-2)
 
-	return tea.NewView(
-		style.Render(
-			lipgloss.JoinVertical(lipgloss.Top,
-				title,
-				p.details.View().String(),
-				p.table.View().String(),
-			),
+	return style.Render(
+		lipgloss.JoinVertical(lipgloss.Top,
+			title,
+			p.details.View(),
+			p.table.View(),
 		),
 	)
 }

internal/tui/tui.go 🔗

@@ -84,6 +84,9 @@ func (a appModel) Init() tea.Cmd {
 		return nil
 	})
 
+	// Enable mouse support.
+	cmds = append(cmds, tea.EnableMouseAllMotion)
+
 	return tea.Batch(cmds...)
 }
 
@@ -391,9 +394,9 @@ func (a *appModel) View() tea.View {
 	a.status.SetKeyMap(a.keyMap)
 	pageView := page.View()
 	components := []string{
-		pageView.String(),
+		pageView,
 	}
-	components = append(components, a.status.View().String())
+	components = append(components, a.status.View())
 
 	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
 	layers := []*lipgloss.Layer{
@@ -406,14 +409,20 @@ func (a *appModel) View() tea.View {
 		)
 	}
 
-	cursor := pageView.Cursor()
-	activeView := a.dialog.ActiveView()
+	var cursor *tea.Cursor
+	if v, ok := page.(util.Cursor); ok {
+		cursor = v.Cursor()
+	}
+	activeView := a.dialog.ActiveModel()
 	if activeView != nil {
-		cursor = activeView.Cursor()
+		cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
+		if v, ok := activeView.(util.Cursor); ok {
+			cursor = v.Cursor()
+		}
 	}
 
 	if a.completions.Open() && cursor != nil {
-		cmp := a.completions.View().String()
+		cmp := a.completions.View()
 		x, y := a.completions.Position()
 		layers = append(
 			layers,
@@ -425,10 +434,11 @@ func (a *appModel) View() tea.View {
 		layers...,
 	)
 
+	var view tea.View
 	t := styles.CurrentTheme()
-	view := tea.NewView(canvas.Render())
-	view.SetBackgroundColor(t.BgBase)
-	view.SetCursor(cursor)
+	view.Layer = canvas
+	view.BackgroundColor = t.BgBase
+	view.Cursor = cursor
 	return view
 }
 

internal/tui/util/util.go 🔗

@@ -6,9 +6,13 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 )
 
+type Cursor interface {
+	Cursor() *tea.Cursor
+}
+
 type Model interface {
 	tea.Model
-	tea.Viewable
+	tea.ViewModel
 }
 
 func CmdHandler(msg tea.Msg) tea.Cmd {