wip dialogs

Kujtim Hoxha created

Change summary

cspell.json                                           |    1 
go.mod                                                |   18 
go.sum                                                |   36 
internal/tui/components/anim/anim.go                  |    6 
internal/tui/components/chat/editor.go                |   27 
internal/tui/components/chat/list.go                  |    6 
internal/tui/components/chat/messages/messages.go     |   10 
internal/tui/components/chat/messages/renderer.go     |    2 
internal/tui/components/chat/messages/tool.go         |   10 
internal/tui/components/chat/sidebar.go               |   36 
internal/tui/components/core/list/list.go             |   13 
internal/tui/components/core/status.go                |   40 
internal/tui/components/dialog/arguments.go           |   13 
internal/tui/components/dialog/commands.go            |   18 
internal/tui/components/dialog/complete.go            |   22 
internal/tui/components/dialog/filepicker.go          |    6 
internal/tui/components/dialog/help.go                |   26 
internal/tui/components/dialog/models.go              |   16 
internal/tui/components/dialog/permission.go          |    4 
internal/tui/components/dialog/session.go             |   28 
internal/tui/components/dialog/theme.go               |   30 
internal/tui/components/dialogs/commands/arguments.go |   16 
internal/tui/components/dialogs/commands/commands.go  |  135 +
internal/tui/components/dialogs/commands/item.go      |   55 
internal/tui/components/dialogs/commands/loader.go    |  204 ++
internal/tui/components/dialogs/dialogs.go            |  164 +
internal/tui/components/dialogs/keys.go               |   37 
internal/tui/components/dialogs/quit/keys.go          |   59 
internal/tui/components/dialogs/quit/quit.go          |  106 
internal/tui/components/logs/details.go               |    4 
internal/tui/components/logs/table.go                 |    4 
internal/tui/components/util/simple-list.go           |   18 
internal/tui/keys.go                                  |   75 
internal/tui/layout/container.go                      |   19 
internal/tui/layout/layout.go                         |    4 
internal/tui/layout/split.go                          |   49 
internal/tui/page/chat.go                             |   15 
internal/tui/page/logs.go                             |   14 
internal/tui/tui.go                                   | 1183 ++++++------
internal/tui/util/util.go                             |    2 
40 files changed, 1,676 insertions(+), 855 deletions(-)

Detailed changes

cspell.json 🔗

@@ -0,0 +1 @@
+{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"}

go.mod 🔗

@@ -11,11 +11,11 @@ require (
 	github.com/aymanbagabas/go-udiff v0.2.0
 	github.com/bmatcuk/doublestar/v4 v4.8.1
 	github.com/catppuccin/go v0.3.0
-	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
-	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1
-	github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6
-	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40
-	github.com/charmbracelet/x/ansi v0.9.2
+	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
+	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c
+	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.20250516160309-24eee56f89fa
 	github.com/fsnotify/fsnotify v1.8.0
 	github.com/go-logfmt/logfmt v0.6.0
 	github.com/google/uuid v1.6.0
@@ -57,12 +57,12 @@ require (
 	github.com/aws/smithy-go v1.20.3 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
-	github.com/charmbracelet/colorprofile v0.3.0 // indirect
-	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da // indirect
+	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-20250327172914-2fdc97757edf // indirect
-	github.com/charmbracelet/x/input v0.3.4 // indirect
+	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.0 // indirect
+	github.com/charmbracelet/x/windows v0.2.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/imaging v1.6.2
 	github.com/dlclark/regexp2 v1.11.4 // indirect

go.sum 🔗

@@ -68,30 +68,30 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
 github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
 github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc=
-github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
-github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
-github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6 h1:AKhOV8dSRU3KpqMgpGME9JU7ouumB2S6hMmD6PRJeTc=
-github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6/go.mod h1:7xBAUTCSADx9mHG0uBf4NDoVpYxMzIQ2j/NMLGdFsFM=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40 h1:SxOUomYAVo5zh+6WCH1bGshlAnSKP0ZeovI0FHAl9kg=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
-github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
-github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da h1:8MGKD5WBtuzfXglq0CnyzVSwGojv57X+H46OL9OUyRA=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
+github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
+github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
+github.com/charmbracelet/glamour/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.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
+github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/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/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
-github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
-github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
+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/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
-github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
+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=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

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

@@ -239,7 +239,7 @@ func (a *anim) updateChars(chars *[]cyclingChar) {
 }
 
 // View renders the animation.
-func (a anim) View() string {
+func (a anim) View() tea.View {
 	t := theme.CurrentTheme()
 	var b strings.Builder
 
@@ -259,10 +259,10 @@ func (a anim) View() string {
 				textStyle.Render(string(c.currentValue)),
 			)
 		}
-		return b.String() + textStyle.Render(a.ellipsis.View())
+		return tea.NewView(b.String() + textStyle.Render(a.ellipsis.View()))
 	}
 
-	return b.String()
+	return tea.NewView(b.String())
 }
 
 func makeGradientRamp(length int) []color.Color {

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

@@ -26,6 +26,7 @@ import (
 type editorCmp struct {
 	width       int
 	height      int
+	x, y        int
 	app         *app.App
 	session     session.Session
 	textarea    textarea.Model
@@ -116,7 +117,7 @@ func (m *editorCmp) openEditor() tea.Cmd {
 }
 
 func (m *editorCmp) Init() tea.Cmd {
-	return textarea.Blink
+	return nil
 }
 
 func (m *editorCmp) send() tea.Cmd {
@@ -212,7 +213,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-func (m *editorCmp) View() string {
+func (m *editorCmp) View() tea.View {
 	t := theme.CurrentTheme()
 
 	// Style the prompt with theme colors
@@ -221,16 +222,23 @@ func (m *editorCmp) View() string {
 		Bold(true).
 		Foreground(t.Primary())
 
+	cursor := m.textarea.Cursor()
+	cursor.X = m.textarea.Cursor().X + m.x + 2
+	cursor.Y = m.textarea.Cursor().Y + m.y + 1
 	if len(m.attachments) == 0 {
-		return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+		view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
+		view.SetCursor(cursor)
+		return view
 	}
 	m.textarea.SetHeight(m.height - 1)
-	return lipgloss.JoinVertical(lipgloss.Top,
+	view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
 		m.attachmentsContent(),
 		lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
 			m.textarea.View(),
 		),
-	)
+	))
+	view.SetCursor(cursor)
+	return view
 }
 
 func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -275,6 +283,12 @@ func (m *editorCmp) BindingKeys() []key.Binding {
 	return bindings
 }
 
+func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
+	m.x = x
+	m.y = y
+	return nil
+}
+
 func CreateTextArea(existing *textarea.Model) textarea.Model {
 	t := theme.CurrentTheme()
 	bgColor := t.Background()
@@ -297,11 +311,12 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
 
 	s.Focused = f
 	s.Blurred = b
-	ta.Styles = s
+	ta.SetStyles(s)
 
 	ta.Prompt = " "
 	ta.ShowLineNumbers = false
 	ta.CharLimit = -1
+	ta.SetVirtualCursor(false)
 
 	if existing != nil {
 		ta.SetValue(existing.Value())

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

@@ -104,11 +104,11 @@ 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() string {
+func (m *messageListCmp) View() tea.View {
 	if len(m.listCmp.Items()) == 0 {
-		return initialScreen()
+		return tea.NewView(initialScreen())
 	}
-	return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
+	return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View().String()))
 }
 
 // handleChildSession handles messages from child sessions (agent tools).

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

@@ -94,20 +94,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() string {
+func (m *messageCmp) View() tea.View {
 	if m.spinning {
-		return m.style().PaddingLeft(1).Render(m.anim.View())
+		return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String()))
 	}
 	if m.message.ID != "" {
 		// this is a user or assistant message
 		switch m.message.Role {
 		case message.User:
-			return m.renderUserMessage()
+			return tea.NewView(m.renderUserMessage())
 		default:
-			return m.renderAssistantMessage()
+			return tea.NewView(m.renderAssistantMessage())
 		}
 	}
-	return "Unknown Message"
+	return tea.NewView(m.style().Render("No message content"))
 }
 
 // GetMessage returns the underlying message data

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

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

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

@@ -134,22 +134,22 @@ 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() string {
+func (m *toolCallCmp) View() tea.View {
 	box := m.style()
 
 	if !m.call.Finished && !m.cancelled {
 		if m.isNested {
-			return box.Render(m.renderPending())
+			return tea.NewView(box.Render(m.renderPending()))
 		}
-		return box.PaddingLeft(1).Render(m.renderPending())
+		return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
 	}
 
 	r := registry.lookup(m.call.Name)
 
 	if m.isNested {
-		return box.Render(r.Render(m))
+		return tea.NewView(box.Render(r.Render(m)))
 	}
-	return box.PaddingLeft(1).Render(r.Render(m))
+	return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
 }
 
 // State management methods

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

@@ -82,26 +82,28 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *sidebarCmp) View() string {
+func (m *sidebarCmp) View() tea.View {
 	baseStyle := styles.BaseStyle()
 
-	return baseStyle.
-		Width(m.width).
-		PaddingLeft(4).
-		PaddingRight(2).
-		Height(m.height - 1).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Top,
-				header(),
-				" ",
-				m.sessionSection(),
-				" ",
-				lspsConfigured(),
-				" ",
-				m.modifiedFiles(),
+	return tea.NewView(
+		baseStyle.
+			Width(m.width).
+			PaddingLeft(4).
+			PaddingRight(2).
+			Height(m.height - 1).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Top,
+					header(),
+					" ",
+					m.sessionSection(),
+					" ",
+					lspsConfigured(),
+					" ",
+					m.modifiedFiles(),
+				),
 			),
-		)
+	)
 }
 
 func (m *sidebarCmp) sessionSection() string {

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

@@ -269,14 +269,19 @@ func (m *model) scrollUp(amount int) {
 // 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() string {
+func (m *model) View() tea.View {
 	if m.viewState.height == 0 || m.viewState.width == 0 {
-		return ""
+		return tea.NewView("") // No content to display
 	}
 	if m.renderState.needsRerender {
 		m.renderVisible()
 	}
-	return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content)
+	return tea.NewView(
+		lipgloss.NewStyle().
+			Padding(m.padding...).
+			Height(m.viewState.height).
+			Render(m.viewState.content),
+	)
 }
 
 // Items returns a copy of all items in the list.
@@ -642,7 +647,7 @@ func (m *model) rerenderItem(inx int) {
 
 // getItemLines converts an item to its rendered lines, including any gap spacing.
 func (m *model) getItemLines(item util.Model) []string {
-	itemLines := strings.Split(item.View(), "\n")
+	itemLines := strings.Split(item.View().String(), "\n")
 	if m.gapSize > 0 {
 		gap := make([]string, m.gapSize)
 		itemLines = append(itemLines, gap...)

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

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/config"
 	"github.com/opencode-ai/opencode/internal/llm/models"
+	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/lsp"
 	"github.com/opencode-ai/opencode/internal/lsp/protocol"
 	"github.com/opencode-ai/opencode/internal/pubsub"
@@ -47,6 +48,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
 		return m, nil
+
+		// Handle sesson messages
 	case chat.SessionSelectedMsg:
 		m.session = msg
 	case chat.SessionClearedMsg:
@@ -57,6 +60,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.session = msg.Payload
 			}
 		}
+
+	// Handle status info
 	case util.InfoMsg:
 		m.info = msg
 		ttl := msg.TTL
@@ -66,6 +71,37 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, m.clearMessageCmd(ttl)
 	case util.ClearStatusMsg:
 		m.info = util.InfoMsg{}
+
+	// Handle persistant logs
+	case pubsub.Event[logging.LogMessage]:
+		if msg.Payload.Persist {
+			switch msg.Payload.Level {
+			case "error":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeError,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			case "info":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeInfo,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			case "warn":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeWarn,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			default:
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeInfo,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			}
+		}
 	}
 	return m, nil
 }
@@ -116,7 +152,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
 	return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
 }
 
-func (m statusCmp) View() string {
+func (m statusCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	modelID := config.Get().Agents[config.AgentCoder].Model
 	model := models.SupportedModels[modelID]
@@ -176,7 +212,7 @@ func (m statusCmp) View() string {
 
 	status += diagnostics
 	status += m.model()
-	return status
+	return tea.NewView(status)
 }
 
 func (m *statusCmp) projectDiagnostics() string {

internal/tui/components/dialog/arguments.go 🔗

@@ -73,12 +73,13 @@ func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) Mu
 		ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
 		ti.SetWidth(40)
 		ti.Prompt = ""
-		ti.Styles.Focused.Placeholder = ti.Styles.Focused.Placeholder.Background(t.Background())
-		ti.Styles.Blurred.Placeholder = ti.Styles.Blurred.Placeholder.Background(t.Background())
-		ti.Styles.Focused.Suggestion = ti.Styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
-		ti.Styles.Blurred.Suggestion = ti.Styles.Blurred.Suggestion.Background(t.Background())
-		ti.Styles.Focused.Text = ti.Styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
-		ti.Styles.Blurred.Text = ti.Styles.Blurred.Text.Background(t.Background())
+		styles := ti.Styles()
+		styles.Focused.Placeholder = styles.Focused.Placeholder.Background(t.Background())
+		styles.Blurred.Placeholder = styles.Blurred.Placeholder.Background(t.Background())
+		styles.Focused.Suggestion = styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
+		styles.Blurred.Suggestion = styles.Blurred.Suggestion.Background(t.Background())
+		styles.Focused.Text = styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
+		styles.Blurred.Text = styles.Blurred.Text.Background(t.Background())
 
 		// Only focus the first input initially
 		if i == 0 {

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

@@ -114,7 +114,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return c, tea.Batch(cmds...)
 }
 
-func (c *commandDialogCmp) View() string {
+func (c *commandDialogCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
@@ -146,16 +146,18 @@ func (c *commandDialogCmp) View() string {
 		lipgloss.Left,
 		title,
 		baseStyle.Width(maxWidth).Render(""),
-		baseStyle.Width(maxWidth).Render(c.listView.View()),
+		baseStyle.Width(maxWidth).Render(c.listView.View().String()),
 		baseStyle.Width(maxWidth).Render(""),
 	)
 
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
+	return tea.NewView(
+		baseStyle.Padding(1, 2).
+			Border(lipgloss.RoundedBorder()).
+			BorderBackground(t.Background()).
+			BorderForeground(t.TextMuted()).
+			Width(lipgloss.Width(content) + 4).
+			Render(content),
+	)
 }
 
 func (c *commandDialogCmp) BindingKeys() []key.Binding {

internal/tui/components/dialog/complete.go 🔗

@@ -202,7 +202,7 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return c, tea.Batch(cmds...)
 }
 
-func (c *completionDialogCmp) View() string {
+func (c *completionDialogCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
@@ -219,15 +219,17 @@ func (c *completionDialogCmp) View() string {
 
 	c.listView.SetMaxWidth(maxWidth)
 
-	return baseStyle.Padding(0, 0).
-		Border(lipgloss.NormalBorder()).
-		BorderBottom(false).
-		BorderRight(false).
-		BorderLeft(false).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(c.width).
-		Render(c.listView.View())
+	return tea.NewView(
+		baseStyle.Padding(0, 0).
+			Border(lipgloss.NormalBorder()).
+			BorderBottom(false).
+			BorderRight(false).
+			BorderLeft(false).
+			BorderBackground(t.Background()).
+			BorderForeground(t.TextMuted()).
+			Width(c.width).
+			Render(c.listView.View().String()),
+	)
 }
 
 func (c *completionDialogCmp) SetWidth(width int) {

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

@@ -258,7 +258,7 @@ func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
 	return f, util.CmdHandler(AttachmentAddedMsg{attachment})
 }
 
-func (f *filepickerCmp) View() string {
+func (f *filepickerCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	const maxVisibleDirs = 20
 	const maxWidth = 80
@@ -349,7 +349,9 @@ func (f *filepickerCmp) View() string {
 		BorderForeground(t.TextMuted()).
 		Width(lipgloss.Width(content) + 4)
 
-	return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
+	return tea.NewView(
+		lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
+	)
 }
 
 type FilepickerCmp interface {

internal/tui/components/dialog/help.go 🔗

@@ -166,7 +166,7 @@ func (h *helpCmp) render() string {
 	return content
 }
 
-func (h *helpCmp) View() string {
+func (h *helpCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
@@ -177,18 +177,20 @@ func (h *helpCmp) View() string {
 		Foreground(t.Primary()).
 		Render("Keyboard Shortcuts")
 
-	return baseStyle.Padding(1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.TextMuted()).
-		Width(h.width).
-		BorderBackground(t.Background()).
-		Render(
-			lipgloss.JoinVertical(lipgloss.Center,
-				header,
-				baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
-				content,
+	return tea.NewView(
+		baseStyle.Padding(1).
+			Border(lipgloss.RoundedBorder()).
+			BorderForeground(t.TextMuted()).
+			Width(h.width).
+			BorderBackground(t.Background()).
+			Render(
+				lipgloss.JoinVertical(lipgloss.Center,
+					header,
+					baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
+					content,
+				),
 			),
-		)
+	)
 }
 
 type HelpCmp interface {

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

@@ -185,7 +185,7 @@ func (m *modelDialogCmp) switchProvider(offset int) {
 	m.setupModelsForProvider(m.provider)
 }
 
-func (m *modelDialogCmp) View() string {
+func (m *modelDialogCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
@@ -220,12 +220,14 @@ func (m *modelDialogCmp) View() string {
 		scrollIndicator,
 	)
 
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
+	return tea.NewView(
+		baseStyle.Padding(1, 2).
+			Border(lipgloss.RoundedBorder()).
+			BorderBackground(t.Background()).
+			BorderForeground(t.TextMuted()).
+			Width(lipgloss.Width(content) + 4).
+			Render(content),
+	)
 }
 
 func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {

internal/tui/components/dialog/permission.go 🔗

@@ -437,8 +437,8 @@ func (p *permissionDialogCmp) render() string {
 		)
 }
 
-func (p *permissionDialogCmp) View() string {
-	return p.render()
+func (p *permissionDialogCmp) View() tea.View {
+	return tea.NewView(p.render())
 }
 
 func (p *permissionDialogCmp) BindingKeys() []key.Binding {

internal/tui/components/dialog/session.go 🔗

@@ -105,17 +105,19 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return s, nil
 }
 
-func (s *sessionDialogCmp) View() string {
+func (s *sessionDialogCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
 	if len(s.sessions) == 0 {
-		return baseStyle.Padding(1, 2).
-			Border(lipgloss.RoundedBorder()).
-			BorderBackground(t.Background()).
-			BorderForeground(t.TextMuted()).
-			Width(40).
-			Render("No sessions available")
+		return tea.NewView(
+			baseStyle.Padding(1, 2).
+				Border(lipgloss.RoundedBorder()).
+				BorderBackground(t.Background()).
+				BorderForeground(t.TextMuted()).
+				Width(40).
+				Render("No sessions available"),
+		)
 	}
 
 	// Calculate max width needed for session titles
@@ -177,11 +179,13 @@ func (s *sessionDialogCmp) View() string {
 		baseStyle.Width(maxWidth).Render(""),
 	)
 
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Render(content)
+	return tea.NewView(
+		baseStyle.Padding(1, 2).
+			Border(lipgloss.RoundedBorder()).
+			BorderBackground(t.Background()).
+			BorderForeground(t.TextMuted()).
+			Render(content),
+	)
 }
 
 func (s *sessionDialogCmp) BindingKeys() []key.Binding {

internal/tui/components/dialog/theme.go 🔗

@@ -122,17 +122,19 @@ func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return t, nil
 }
 
-func (t *themeDialogCmp) View() string {
+func (t *themeDialogCmp) View() tea.View {
 	currentTheme := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
 	if len(t.themes) == 0 {
-		return baseStyle.Padding(1, 2).
-			Border(lipgloss.RoundedBorder()).
-			BorderBackground(currentTheme.Background()).
-			BorderForeground(currentTheme.TextMuted()).
-			Width(40).
-			Render("No themes available")
+		return tea.NewView(
+			baseStyle.Padding(1, 2).
+				Border(lipgloss.RoundedBorder()).
+				BorderBackground(currentTheme.Background()).
+				BorderForeground(currentTheme.TextMuted()).
+				Width(40).
+				Render("No themes available"),
+		)
 	}
 
 	// Calculate max width needed for theme names
@@ -175,12 +177,14 @@ func (t *themeDialogCmp) View() string {
 		baseStyle.Width(maxWidth).Render(""),
 	)
 
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(currentTheme.Background()).
-		BorderForeground(currentTheme.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
+	return tea.NewView(
+		baseStyle.Padding(1, 2).
+			Border(lipgloss.RoundedBorder()).
+			BorderBackground(currentTheme.Background()).
+			BorderForeground(currentTheme.TextMuted()).
+			Width(lipgloss.Width(content) + 4).
+			Render(content),
+	)
 }
 
 func (t *themeDialogCmp) BindingKeys() []key.Binding {

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

@@ -0,0 +1,16 @@
+package commands
+
+// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
+type ShowArgumentsDialogMsg struct {
+	CommandID string
+	Content   string
+	ArgNames  []string
+}
+
+// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
+type CloseArgumentsDialogMsg struct {
+	Submit    bool
+	CommandID string
+	Content   string
+	Args      map[string]string
+}

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

@@ -0,0 +1,135 @@
+package commands
+
+import (
+	"github.com/charmbracelet/bubbles/v2/textinput"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
+	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+const (
+	id dialogs.DialogID = "commands"
+)
+
+// Command represents a command that can be executed
+type Command struct {
+	ID          string
+	Title       string
+	Description string
+	Handler     func(cmd Command) tea.Cmd
+}
+
+// CommandsDialog represents the commands dialog.
+type CommandsDialog interface {
+	dialogs.DialogModel
+}
+
+type commandDialogCmp struct {
+	width   int
+	wWidth  int // Width of the terminal window
+	wHeight int // Height of the terminal window
+
+	commandList list.ListModel
+	input       textinput.Model
+	oldCursor   tea.Cursor
+}
+
+func NewCommandDialog() CommandsDialog {
+	ti := textinput.New()
+	ti.Placeholder = "Type a command or search..."
+	ti.SetVirtualCursor(false)
+	ti.Focus()
+	ti.SetWidth(60 - 7)
+	commandList := list.New()
+	return &commandDialogCmp{
+		commandList: commandList,
+		width:       60,
+		input:       ti,
+	}
+}
+
+func (c *commandDialogCmp) Init() tea.Cmd {
+	logging.Info("Initializing commands dialog")
+	commands, err := LoadCustomCommands()
+	if err != nil {
+		return util.ReportError(err)
+	}
+	logging.Info("Commands loaded", "count", len(commands))
+
+	commandItems := make([]util.Model, 0, len(commands))
+
+	for _, cmd := range commands {
+		commandItems = append(commandItems, NewCommandItem(cmd))
+	}
+	c.commandList.SetItems(commandItems)
+	return c.commandList.Init()
+}
+
+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		c.wWidth = msg.Width
+		c.wHeight = msg.Height
+		return c, c.commandList.SetSize(60, min(len(c.commandList.Items())*2, c.wHeight/2))
+	}
+	u, cmd := c.input.Update(msg)
+	c.input = u
+	return c, cmd
+}
+
+func (c *commandDialogCmp) View() tea.View {
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		c.inputStyle().Render(c.input.View()),
+		c.commandList.View().String(),
+	)
+
+	v := tea.NewView(c.style().Render(content))
+	v.SetCursor(c.getCursor())
+	return v
+}
+
+func (c *commandDialogCmp) getCursor() *tea.Cursor {
+	cursor := c.input.Cursor()
+	offset := 10 + 1
+	cursor.Y += offset
+	_, col := c.Position()
+	cursor.X = c.input.Cursor().X + col + 2
+	return cursor
+}
+
+func (c *commandDialogCmp) inputStyle() lipgloss.Style {
+	t := theme.CurrentTheme()
+	return styles.BaseStyle().
+		BorderStyle(lipgloss.NormalBorder()).
+		BorderForeground(t.TextMuted()).
+		BorderBackground(t.Background()).
+		BorderBottom(true)
+}
+
+func (c *commandDialogCmp) style() lipgloss.Style {
+	t := theme.CurrentTheme()
+	return styles.BaseStyle().
+		Width(c.width).
+		Padding(0, 1, 1, 1).
+		Border(lipgloss.RoundedBorder()).
+		BorderBackground(t.Background()).
+		BorderForeground(t.TextMuted())
+}
+
+func (q *commandDialogCmp) Position() (int, int) {
+	row := 10
+	col := q.wWidth / 2
+	col -= q.width / 2
+	return row, col
+}
+
+func (c *commandDialogCmp) ID() dialogs.DialogID {
+	return id
+}

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

@@ -0,0 +1,55 @@
+package commands
+
+import (
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type CommandItem interface {
+	util.Model
+	layout.Focusable
+}
+
+type commandItem struct {
+	command Command
+	focus   bool
+}
+
+func NewCommandItem(command Command) CommandItem {
+	return &commandItem{
+		command: command,
+	}
+}
+
+// Init implements CommandItem.
+func (c *commandItem) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements CommandItem.
+func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return c, nil
+}
+
+// View implements CommandItem.
+func (c *commandItem) View() tea.View {
+	return tea.NewView(c.command.Title)
+}
+
+// Blur implements CommandItem.
+func (c *commandItem) Blur() tea.Cmd {
+	c.focus = false
+	return nil
+}
+
+// Focus implements CommandItem.
+func (c *commandItem) Focus() tea.Cmd {
+	c.focus = true
+	return nil
+}
+
+// IsFocused implements CommandItem.
+func (c *commandItem) IsFocused() bool {
+	return c.focus
+}

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

@@ -0,0 +1,204 @@
+package commands
+
+import (
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+const (
+	UserCommandPrefix    = "user:"
+	ProjectCommandPrefix = "project:"
+)
+
+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
+
+type commandLoader struct {
+	sources []commandSource
+}
+
+type commandSource struct {
+	path   string
+	prefix string
+}
+
+func LoadCustomCommands() ([]Command, error) {
+	cfg := config.Get()
+	if cfg == nil {
+		return nil, fmt.Errorf("config not loaded")
+	}
+
+	loader := &commandLoader{
+		sources: buildCommandSources(cfg),
+	}
+
+	return loader.loadAll()
+}
+
+func buildCommandSources(cfg *config.Config) []commandSource {
+	var sources []commandSource
+
+	// XDG config directory
+	if dir := getXDGCommandsDir(); dir != "" {
+		sources = append(sources, commandSource{
+			path:   dir,
+			prefix: UserCommandPrefix,
+		})
+	}
+
+	// Home directory
+	if home, err := os.UserHomeDir(); err == nil {
+		sources = append(sources, commandSource{
+			path:   filepath.Join(home, ".opencode", "commands"),
+			prefix: UserCommandPrefix,
+		})
+	}
+
+	// Project directory
+	sources = append(sources, commandSource{
+		path:   filepath.Join(cfg.Data.Directory, "commands"),
+		prefix: ProjectCommandPrefix,
+	})
+
+	return sources
+}
+
+func getXDGCommandsDir() string {
+	xdgHome := os.Getenv("XDG_CONFIG_HOME")
+	if xdgHome == "" {
+		if home, err := os.UserHomeDir(); err == nil {
+			xdgHome = filepath.Join(home, ".config")
+		}
+	}
+	if xdgHome != "" {
+		return filepath.Join(xdgHome, "opencode", "commands")
+	}
+	return ""
+}
+
+func (l *commandLoader) loadAll() ([]Command, error) {
+	var commands []Command
+
+	for _, source := range l.sources {
+		if cmds, err := l.loadFromSource(source); err == nil {
+			commands = append(commands, cmds...)
+		}
+	}
+
+	return commands, nil
+}
+
+func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
+	if err := ensureDir(source.path); err != nil {
+		return nil, err
+	}
+
+	var commands []Command
+
+	err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
+		if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
+			return err
+		}
+
+		cmd, err := l.loadCommand(path, source.path, source.prefix)
+		if err != nil {
+			return nil // Skip invalid files
+		}
+
+		commands = append(commands, cmd)
+		return nil
+	})
+
+	return commands, err
+}
+
+func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return Command{}, err
+	}
+
+	id := buildCommandID(path, baseDir, prefix)
+
+	return Command{
+		ID:          id,
+		Title:       id,
+		Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)),
+		Handler:     createCommandHandler(id, string(content)),
+	}, nil
+}
+
+func buildCommandID(path, baseDir, prefix string) string {
+	relPath, _ := filepath.Rel(baseDir, path)
+	parts := strings.Split(relPath, string(filepath.Separator))
+
+	// Remove .md extension from last part
+	if len(parts) > 0 {
+		lastIdx := len(parts) - 1
+		parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
+	}
+
+	return prefix + strings.Join(parts, ":")
+}
+
+func createCommandHandler(id string, content string) func(Command) tea.Cmd {
+	return func(cmd Command) tea.Cmd {
+		args := extractArgNames(content)
+
+		if len(args) > 0 {
+			return util.CmdHandler(ShowArgumentsDialogMsg{
+				CommandID: id,
+				Content:   content,
+				ArgNames:  args,
+			})
+		}
+
+		return util.CmdHandler(CommandRunCustomMsg{
+			Content: content,
+			Args:    nil,
+		})
+	}
+}
+
+func extractArgNames(content string) []string {
+	matches := namedArgPattern.FindAllStringSubmatch(content, -1)
+	if len(matches) == 0 {
+		return nil
+	}
+
+	seen := make(map[string]bool)
+	var args []string
+
+	for _, match := range matches {
+		arg := match[1]
+		if !seen[arg] {
+			seen[arg] = true
+			args = append(args, arg)
+		}
+	}
+
+	return args
+}
+
+func ensureDir(path string) error {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		return os.MkdirAll(path, 0755)
+	}
+	return nil
+}
+
+func isMarkdownFile(name string) bool {
+	return strings.HasSuffix(strings.ToLower(name), ".md")
+}
+
+type CommandRunCustomMsg struct {
+	Content string
+	Args    map[string]string
+}

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

@@ -0,0 +1,164 @@
+package dialogs
+
+import (
+	"slices"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type DialogID string
+
+// DialogModel represents a dialog component that can be displayed.
+type DialogModel interface {
+	util.Model
+	Position() (int, int)
+	ID() DialogID
+}
+
+// CloseCallback allows dialogs to perform cleanup when closed.
+type CloseCallback interface {
+	Close() tea.Cmd
+}
+
+// AbsolutePositionable is an interface for components that can set their position
+type AbsolutePositionable interface {
+	SetPosition(x, y int)
+}
+
+// OpenDialogMsg is sent to open a new dialog with specified dimensions.
+type OpenDialogMsg struct {
+	Model DialogModel
+}
+
+// CloseDialogMsg is sent to close the topmost dialog.
+type CloseDialogMsg struct{}
+
+// DialogCmp manages a stack of dialogs with keyboard navigation.
+type DialogCmp interface {
+	tea.Model
+
+	Dialogs() []DialogModel
+	HasDialogs() bool
+	GetLayers() []*lipgloss.Layer
+	ActiveView() *tea.View
+}
+
+type dialogCmp struct {
+	width, height int
+	dialogs       []DialogModel
+	idMap         map[DialogID]int
+	keymap        KeyMap
+}
+
+// NewDialogCmp creates a new dialog manager.
+func NewDialogCmp() DialogCmp {
+	return dialogCmp{
+		dialogs: []DialogModel{},
+		keymap:  DefaultKeymap(),
+		idMap:   make(map[DialogID]int),
+	}
+}
+
+func (d dialogCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update handles dialog lifecycle and forwards messages to the active dialog.
+func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		var cmds []tea.Cmd
+		d.width = msg.Width
+		d.height = msg.Height
+		for i := range d.dialogs {
+			u, cmd := d.dialogs[i].Update(msg)
+			d.dialogs[i] = u.(DialogModel)
+			cmds = append(cmds, cmd)
+		}
+		return d, tea.Batch(cmds...)
+	case OpenDialogMsg:
+		return d.handleOpen(msg)
+	case CloseDialogMsg:
+		if len(d.dialogs) == 0 {
+			return d, nil
+		}
+		inx := len(d.dialogs) - 1
+		dialog := d.dialogs[inx]
+		delete(d.idMap, dialog.ID())
+		d.dialogs = d.dialogs[:len(d.dialogs)-1]
+		if closeable, ok := dialog.(CloseCallback); ok {
+			return d, closeable.Close()
+		}
+		return d, nil
+	case tea.KeyPressMsg:
+		if key.Matches(msg, d.keymap.Close) {
+			return d, util.CmdHandler(CloseDialogMsg{})
+		}
+	}
+	if d.HasDialogs() {
+		lastIndex := len(d.dialogs) - 1
+		u, cmd := d.dialogs[lastIndex].Update(msg)
+		d.dialogs[lastIndex] = u.(DialogModel)
+		return d, cmd
+	}
+	return d, nil
+}
+
+func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
+	if d.HasDialogs() {
+		dialog := d.dialogs[len(d.dialogs)-1]
+		if dialog.ID() == msg.Model.ID() {
+			return d, nil // Do not open a dialog if it's already the topmost one
+		}
+		if dialog.ID() == "quit" {
+			return d, nil // Do not open dialogs ontop of quit
+		}
+	}
+	// if the dialog is already in thel stack make it the last item
+	if _, ok := d.idMap[msg.Model.ID()]; ok {
+		existing := d.dialogs[d.idMap[msg.Model.ID()]]
+		// Reuse the model so we keep the state
+		msg.Model = existing
+		d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
+	}
+	d.idMap[msg.Model.ID()] = len(d.dialogs)
+	d.dialogs = append(d.dialogs, msg.Model)
+	var cmds []tea.Cmd
+	cmd := msg.Model.Init()
+	cmds = append(cmds, cmd)
+	_, cmd = msg.Model.Update(tea.WindowSizeMsg{
+		Width:  d.width,
+		Height: d.height,
+	})
+	cmds = append(cmds, cmd)
+	return d, tea.Batch(cmds...)
+}
+
+func (d dialogCmp) Dialogs() []DialogModel {
+	return d.dialogs
+}
+
+func (d dialogCmp) ActiveView() *tea.View {
+	if len(d.dialogs) == 0 {
+		return nil
+	}
+	view := d.dialogs[len(d.dialogs)-1].View()
+	return &view
+}
+
+func (d dialogCmp) GetLayers() []*lipgloss.Layer {
+	layers := []*lipgloss.Layer{}
+	for _, dialog := range d.Dialogs() {
+		dialogView := dialog.View().String()
+		row, col := dialog.Position()
+		layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
+	}
+	return layers
+}
+
+func (d dialogCmp) HasDialogs() bool {
+	return len(d.dialogs) > 0
+}

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

@@ -0,0 +1,37 @@
+package dialogs
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+// KeyMap defines keyboard bindings for dialog management.
+type KeyMap struct {
+	Close key.Binding
+}
+
+func DefaultKeymap() KeyMap {
+	return KeyMap{
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Close,
+	}
+}

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

@@ -0,0 +1,59 @@
+package quit
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+// KeyMap defines the keyboard bindings for the quit dialog.
+type KeyMap struct {
+	LeftRight  key.Binding
+	EnterSpace key.Binding
+	Yes        key.Binding
+	No         key.Binding
+	Tab        key.Binding
+}
+
+func DefaultKeymap() KeyMap {
+	return KeyMap{
+		LeftRight: key.NewBinding(
+			key.WithKeys("left", "right"),
+			key.WithHelp("←/→", "switch options"),
+		),
+		EnterSpace: key.NewBinding(
+			key.WithKeys("enter", " "),
+			key.WithHelp("enter/space", "confirm"),
+		),
+		Yes: key.NewBinding(
+			key.WithKeys("y", "Y", "ctrl+c"),
+			key.WithHelp("y/Y/ctrl+c", "yes"),
+		),
+		No: key.NewBinding(
+			key.WithKeys("n", "N"),
+			key.WithHelp("n/N", "no"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "switch options"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.LeftRight,
+		k.EnterSpace,
+	}
+}

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

@@ -1,87 +1,74 @@
-package dialog
+package quit
 
 import (
-	"strings"
-
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/styles"
 	"github.com/opencode-ai/opencode/internal/tui/theme"
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
-const question = "Are you sure you want to quit?"
-
-type CloseQuitMsg struct{}
+const (
+	question                  = "Are you sure you want to quit?"
+	id       dialogs.DialogID = "quit"
+)
 
+// QuitDialog represents a confirmation dialog for quitting the application.
 type QuitDialog interface {
-	util.Model
+	dialogs.DialogModel
 	layout.Bindings
 }
 
 type quitDialogCmp struct {
-	selectedNo bool
-}
+	wWidth  int
+	wHeight int
 
-type helpMapping struct {
-	LeftRight  key.Binding
-	EnterSpace key.Binding
-	Yes        key.Binding
-	No         key.Binding
-	Tab        key.Binding
+	selectedNo bool // true if "No" button is selected
+	keymap     KeyMap
 }
 
-var helpKeys = helpMapping{
-	LeftRight: key.NewBinding(
-		key.WithKeys("left", "right"),
-		key.WithHelp("←/→", "switch options"),
-	),
-	EnterSpace: key.NewBinding(
-		key.WithKeys("enter", " "),
-		key.WithHelp("enter/space", "confirm"),
-	),
-	Yes: key.NewBinding(
-		key.WithKeys("y", "Y"),
-		key.WithHelp("y/Y", "yes"),
-	),
-	No: key.NewBinding(
-		key.WithKeys("n", "N"),
-		key.WithHelp("n/N", "no"),
-	),
-	Tab: key.NewBinding(
-		key.WithKeys("tab"),
-		key.WithHelp("tab", "switch options"),
-	),
+// NewQuitDialog creates a new quit confirmation dialog.
+func NewQuitDialog() QuitDialog {
+	return &quitDialogCmp{
+		selectedNo: true, // Default to "No" for safety
+		keymap:     DefaultKeymap(),
+	}
 }
 
 func (q *quitDialogCmp) Init() tea.Cmd {
 	return nil
 }
 
+// Update handles keyboard input for the quit dialog.
 func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		q.wWidth = msg.Width
+		q.wHeight = msg.Height
 	case tea.KeyPressMsg:
 		switch {
-		case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
+		case key.Matches(msg, q.keymap.LeftRight) || key.Matches(msg, q.keymap.Tab):
 			q.selectedNo = !q.selectedNo
 			return q, nil
-		case key.Matches(msg, helpKeys.EnterSpace):
+		case key.Matches(msg, q.keymap.EnterSpace):
 			if !q.selectedNo {
 				return q, tea.Quit
 			}
-			return q, util.CmdHandler(CloseQuitMsg{})
-		case key.Matches(msg, helpKeys.Yes):
+			return q, util.CmdHandler(dialogs.CloseDialogMsg{})
+		case key.Matches(msg, q.keymap.Yes):
 			return q, tea.Quit
-		case key.Matches(msg, helpKeys.No):
-			return q, util.CmdHandler(CloseQuitMsg{})
+		case key.Matches(msg, q.keymap.No):
+			return q, util.CmdHandler(dialogs.CloseDialogMsg{})
 		}
 	}
 	return q, nil
 }
 
-func (q *quitDialogCmp) View() string {
+// View renders the quit dialog with Yes/No buttons.
+func (q *quitDialogCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
@@ -100,13 +87,9 @@ func (q *quitDialogCmp) View() string {
 	yesButton := yesStyle.Padding(0, 1).Render("Yes")
 	noButton := noStyle.Padding(0, 1).Render("No")
 
-	buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render("  "), noButton)
-
-	width := lipgloss.Width(question)
-	remainingWidth := width - lipgloss.Width(buttons)
-	if remainingWidth > 0 {
-		buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
-	}
+	buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
+		lipgloss.JoinHorizontal(lipgloss.Center, yesButton, spacerStyle.Render("  "), noButton),
+	)
 
 	content := baseStyle.Render(
 		lipgloss.JoinVertical(
@@ -123,17 +106,24 @@ func (q *quitDialogCmp) View() string {
 		BorderBackground(t.Background()).
 		BorderForeground(t.TextMuted())
 
-	return quitDialogStyle.
-		Width(lipgloss.Width(content) + quitDialogStyle.GetHorizontalFrameSize()).
-		Render(content)
+	return tea.NewView(
+		quitDialogStyle.Render(content),
+	)
 }
 
 func (q *quitDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(helpKeys)
+	return layout.KeyMapToSlice(q.keymap)
 }
 
-func NewQuitCmp() QuitDialog {
-	return &quitDialogCmp{
-		selectedNo: true,
-	}
+func (q *quitDialogCmp) Position() (int, int) {
+	row := q.wHeight / 2
+	row -= 7 / 2
+	col := q.wWidth / 2
+	col -= (lipgloss.Width(question) + 4) / 2
+
+	return row, col
+}
+
+func (q *quitDialogCmp) ID() dialogs.DialogID {
+	return id
 }

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

@@ -114,8 +114,8 @@ func getLevelStyle(level string) lipgloss.Style {
 	}
 }
 
-func (i *detailCmp) View() string {
-	return i.viewport.View()
+func (i *detailCmp) View() tea.View {
+	return tea.NewView(i.viewport.View())
 }
 
 func (i *detailCmp) GetSize() (int, int) {

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

@@ -60,12 +60,12 @@ func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return i, tea.Batch(cmds...)
 }
 
-func (i *tableCmp) View() string {
+func (i *tableCmp) View() tea.View {
 	t := theme.CurrentTheme()
 	defaultStyles := table.DefaultStyles()
 	defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
 	i.table.SetStyles(defaultStyles)
-	return i.table.View()
+	return tea.NewView(i.table.View())
 }
 
 func (i *tableCmp) GetSize() (int, int) {

internal/tui/components/util/simple-list.go 🔗

@@ -110,7 +110,7 @@ func (c *simpleListCmp[T]) SetMaxWidth(width int) {
 	c.maxWidth = width
 }
 
-func (c *simpleListCmp[T]) View() string {
+func (c *simpleListCmp[T]) View() tea.View {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
@@ -120,11 +120,13 @@ func (c *simpleListCmp[T]) View() string {
 	startIdx := 0
 
 	if len(items) <= 0 {
-		return baseStyle.
-			Background(t.Background()).
-			Padding(0, 1).
-			Width(maxWidth).
-			Render(c.fallbackMsg)
+		return tea.NewView(
+			baseStyle.
+				Background(t.Background()).
+				Padding(0, 1).
+				Width(maxWidth).
+				Render(c.fallbackMsg),
+		)
 	}
 
 	if len(items) > maxVisibleItems {
@@ -146,7 +148,9 @@ func (c *simpleListCmp[T]) View() string {
 		listItems = append(listItems, title)
 	}
 
-	return lipgloss.JoinVertical(lipgloss.Left, listItems...)
+	return tea.NewView(
+		lipgloss.JoinVertical(lipgloss.Left, listItems...),
+	)
 }
 
 func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {

internal/tui/keys.go 🔗

@@ -0,0 +1,75 @@
+package tui
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Logs          key.Binding
+	Quit          key.Binding
+	Help          key.Binding
+	SwitchSession key.Binding
+	Commands      key.Binding
+	FilePicker    key.Binding
+	Models        key.Binding
+	SwitchTheme   key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Logs: key.NewBinding(
+			key.WithKeys("ctrl+l"),
+			key.WithHelp("ctrl+l", "logs"),
+		),
+
+		Quit: key.NewBinding(
+			key.WithKeys("ctrl+c"),
+			key.WithHelp("ctrl+c", "quit"),
+		),
+
+		Help: key.NewBinding(
+			key.WithKeys("ctrl+_"),
+			key.WithHelp("ctrl+?", "toggle help"),
+		),
+
+		SwitchSession: key.NewBinding(
+			key.WithKeys("ctrl+s"),
+			key.WithHelp("ctrl+s", "switch session"),
+		),
+
+		Commands: key.NewBinding(
+			key.WithKeys("ctrl+k"),
+			key.WithHelp("ctrl+k", "commands"),
+		),
+		FilePicker: key.NewBinding(
+			key.WithKeys("ctrl+f"),
+			key.WithHelp("ctrl+f", "select files to upload"),
+		),
+		Models: key.NewBinding(
+			key.WithKeys("ctrl+o"),
+			key.WithHelp("ctrl+o", "model selection"),
+		),
+
+		SwitchTheme: key.NewBinding(
+			key.WithKeys("ctrl+t"),
+			key.WithHelp("ctrl+t", "switch theme"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{}
+}

internal/tui/layout/container.go 🔗

@@ -12,11 +12,14 @@ type Container interface {
 	util.Model
 	Sizeable
 	Bindings
+	Positionable
 }
 type container struct {
 	width  int
 	height int
 
+	x, y int
+
 	content util.Model
 
 	// Style options
@@ -42,7 +45,7 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return c, cmd
 }
 
-func (c *container) View() string {
+func (c *container) View() tea.View {
 	t := theme.CurrentTheme()
 	style := lipgloss.NewStyle()
 	width := c.width
@@ -76,7 +79,10 @@ func (c *container) View() string {
 		PaddingBottom(c.paddingBottom).
 		PaddingLeft(c.paddingLeft)
 
-	return style.Render(c.content.View())
+	contentView := c.content.View()
+	view := tea.NewView(style.Render(contentView.String()))
+	view.SetCursor(contentView.Cursor())
+	return view
 }
 
 func (c *container) SetSize(width, height int) tea.Cmd {
@@ -115,6 +121,15 @@ func (c *container) GetSize() (int, int) {
 	return c.width, c.height
 }
 
+func (c *container) SetPosition(x, y int) tea.Cmd {
+	c.x = x
+	c.y = y
+	if positionable, ok := c.content.(Positionable); ok {
+		return positionable.SetPosition(x, y)
+	}
+	return nil
+}
+
 func (c *container) BindingKeys() []key.Binding {
 	if b, ok := c.content.(Bindings); ok {
 		return b.BindingKeys()

internal/tui/layout/layout.go 🔗

@@ -22,6 +22,10 @@ type Bindings interface {
 	BindingKeys() []key.Binding
 }
 
+type Positionable interface {
+	SetPosition(x, y int) tea.Cmd
+}
+
 func KeyMapToSlice(t any) (bindings []key.Binding) {
 	typ := reflect.TypeOf(t)
 	if typ.Kind() != reflect.Struct {

internal/tui/layout/split.go 🔗

@@ -86,17 +86,17 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return s, tea.Batch(cmds...)
 }
 
-func (s *splitPaneLayout) View() string {
+func (s *splitPaneLayout) View() tea.View {
 	var topSection string
 
 	if s.leftPanel != nil && s.rightPanel != nil {
 		leftView := s.leftPanel.View()
 		rightView := s.rightPanel.View()
-		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
+		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
 	} else if s.leftPanel != nil {
-		topSection = s.leftPanel.View()
+		topSection = s.leftPanel.View().String()
 	} else if s.rightPanel != nil {
-		topSection = s.rightPanel.View()
+		topSection = s.rightPanel.View().String()
 	} else {
 		topSection = ""
 	}
@@ -105,25 +105,33 @@ func (s *splitPaneLayout) View() string {
 
 	if s.bottomPanel != nil && topSection != "" {
 		bottomView := s.bottomPanel.View()
-		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
+		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
 	} else if s.bottomPanel != nil {
-		finalView = s.bottomPanel.View()
+		finalView = s.bottomPanel.View().String()
 	} else {
 		finalView = topSection
 	}
 
-	if finalView != "" {
-		t := theme.CurrentTheme()
+	// 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()
+	}
 
-		style := lipgloss.NewStyle().
-			Width(s.width).
-			Height(s.height).
-			Background(t.Background())
+	t := theme.CurrentTheme()
 
-		return style.Render(finalView)
-	}
+	style := lipgloss.NewStyle().
+		Width(s.width).
+		Height(s.height).
+		Background(t.Background())
 
-	return finalView
+	view := tea.NewView(style.Render(finalView))
+	view.SetCursor(cursor)
+	return view
 }
 
 func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
@@ -131,6 +139,7 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 	s.height = height
 
 	var topHeight, bottomHeight int
+	var cmds []tea.Cmd
 	if s.bottomPanel != nil {
 		topHeight = int(float64(height) * s.verticalRatio)
 		bottomHeight = height - topHeight
@@ -151,20 +160,28 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 		rightWidth = width
 	}
 
-	var cmds []tea.Cmd
 	if s.leftPanel != nil {
 		cmd := s.leftPanel.SetSize(leftWidth, topHeight)
 		cmds = append(cmds, cmd)
+		if positionable, ok := s.leftPanel.(Positionable); ok {
+			cmds = append(cmds, positionable.SetPosition(0, 0))
+		}
 	}
 
 	if s.rightPanel != nil {
 		cmd := s.rightPanel.SetSize(rightWidth, topHeight)
 		cmds = append(cmds, cmd)
+		if positionable, ok := s.rightPanel.(Positionable); ok {
+			cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
+		}
 	}
 
 	if s.bottomPanel != nil {
 		cmd := s.bottomPanel.SetSize(width, bottomHeight)
 		cmds = append(cmds, cmd)
+		if positionable, ok := s.bottomPanel.(Positionable); ok {
+			cmds = append(cmds, positionable.SetPosition(0, topHeight))
+		}
 	}
 	return tea.Batch(cmds...)
 }

internal/tui/page/chat.go 🔗

@@ -185,7 +185,7 @@ func (p *chatPage) GetSize() (int, int) {
 	return p.layout.GetSize()
 }
 
-func (p *chatPage) View() string {
+func (p *chatPage) View() tea.View {
 	layoutView := p.layout.View()
 
 	if p.showCompletionDialog {
@@ -195,15 +195,20 @@ func (p *chatPage) View() string {
 		p.completionDialog.SetWidth(editorWidth)
 		overlay := p.completionDialog.View()
 
-		layoutView = layout.PlaceOverlay(
+		viewStr := layout.PlaceOverlay(
 			0,
-			layoutHeight-editorHeight-lipgloss.Height(overlay),
-			overlay,
-			layoutView,
+			layoutHeight-editorHeight-lipgloss.Height(overlay.String()),
+			overlay.String(),
+			layoutView.String(),
 			false,
 		)
+
+		view := tea.NewView(viewStr)
+		view.SetCursor(overlay.Cursor())
+		return view
 	}
 
+	logging.Info("Cursor in page", "c", layoutView.Cursor())
 	return layoutView
 }
 

internal/tui/page/logs.go 🔗

@@ -42,12 +42,16 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return p, tea.Batch(cmds...)
 }
 
-func (p *logsPage) View() string {
+func (p *logsPage) View() tea.View {
 	style := styles.BaseStyle().Width(p.width).Height(p.height)
-	return style.Render(lipgloss.JoinVertical(lipgloss.Top,
-		p.table.View(),
-		p.details.View(),
-	))
+	return tea.NewView(
+		style.Render(
+			lipgloss.JoinVertical(lipgloss.Top,
+				p.table.View().String(),
+				p.details.View().String(),
+			),
+		),
+	)
 }
 
 func (p *logsPage) BindingKeys() []key.Binding {

internal/tui/tui.go 🔗

@@ -1,676 +1,635 @@
 package tui
 
 import (
-	"context"
-	"fmt"
-	"strings"
-
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/agent"
 	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/permission"
 	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/components/chat"
 	"github.com/opencode-ai/opencode/internal/tui/components/core"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
+	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
+	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/page"
 	"github.com/opencode-ai/opencode/internal/tui/theme"
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
-type keyMap struct {
-	Logs          key.Binding
-	Quit          key.Binding
-	Help          key.Binding
-	SwitchSession key.Binding
-	Commands      key.Binding
-	Filepicker    key.Binding
-	Models        key.Binding
-	SwitchTheme   key.Binding
-}
-
-type startCompactSessionMsg struct{}
-
-const (
-	quitKey = "q"
-)
-
-var keys = keyMap{
-	Logs: key.NewBinding(
-		key.WithKeys("ctrl+l"),
-		key.WithHelp("ctrl+l", "logs"),
-	),
-
-	Quit: key.NewBinding(
-		key.WithKeys("ctrl+c"),
-		key.WithHelp("ctrl+c", "quit"),
-	),
-	Help: key.NewBinding(
-		key.WithKeys("ctrl+_"),
-		key.WithHelp("ctrl+?", "toggle help"),
-	),
-
-	SwitchSession: key.NewBinding(
-		key.WithKeys("ctrl+s"),
-		key.WithHelp("ctrl+s", "switch session"),
-	),
-
-	Commands: key.NewBinding(
-		key.WithKeys("ctrl+k"),
-		key.WithHelp("ctrl+k", "commands"),
-	),
-	Filepicker: key.NewBinding(
-		key.WithKeys("ctrl+f"),
-		key.WithHelp("ctrl+f", "select files to upload"),
-	),
-	Models: key.NewBinding(
-		key.WithKeys("ctrl+o"),
-		key.WithHelp("ctrl+o", "model selection"),
-	),
-
-	SwitchTheme: key.NewBinding(
-		key.WithKeys("ctrl+t"),
-		key.WithHelp("ctrl+t", "switch theme"),
-	),
-}
-
-var helpEsc = key.NewBinding(
-	key.WithKeys("?"),
-	key.WithHelp("?", "toggle help"),
-)
-
-var returnKey = key.NewBinding(
-	key.WithKeys("esc"),
-	key.WithHelp("esc", "close"),
-)
-
-var logsKeyReturnKey = key.NewBinding(
-	key.WithKeys("esc", "backspace", quitKey),
-	key.WithHelp("esc/q", "go back"),
-)
+// type startCompactSessionMsg struct{}
 
 type appModel struct {
-	width, height   int
-	currentPage     page.PageID
-	previousPage    page.PageID
-	pages           map[page.PageID]util.Model
-	loadedPages     map[page.PageID]bool
-	status          core.StatusCmp
-	app             *app.App
-	selectedSession session.Session
-
-	showPermissions bool
-	permissions     dialog.PermissionDialogCmp
-
-	showHelp bool
-	help     dialog.HelpCmp
-
-	showQuit bool
-	quit     dialog.QuitDialog
-
-	showSessionDialog bool
-	sessionDialog     dialog.SessionDialog
-
-	showCommandDialog bool
-	commandDialog     dialog.CommandDialog
-	commands          []dialog.Command
-
-	showModelDialog bool
-	modelDialog     dialog.ModelDialog
-
-	showInitDialog bool
-	initDialog     dialog.InitDialogCmp
-
-	showFilepicker bool
-	filepicker     dialog.FilepickerCmp
-
-	showThemeDialog bool
-	themeDialog     dialog.ThemeDialog
-
-	showMultiArgumentsDialog bool
-	multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
-
-	isCompacting      bool
-	compactingMessage string
+	width, height int
+	keyMap        KeyMap
+
+	currentPage  page.PageID
+	previousPage page.PageID
+	pages        map[page.PageID]util.Model
+	loadedPages  map[page.PageID]bool
+
+	status core.StatusCmp
+
+	app *app.App
+
+	// selectedSession session.Session
+	//
+	// showPermissions bool
+	// permissions     dialog.PermissionDialogCmp
+	//
+	// showHelp bool
+	// help     dialog.HelpCmp
+	//
+	// showSessionDialog bool
+	// sessionDialog     dialog.SessionDialog
+	//
+	// showCommandDialog bool
+	// commandDialog     dialog.CommandDialog
+	// commands          []dialog.Command
+	//
+	// showModelDialog bool
+	// modelDialog     dialog.ModelDialog
+	//
+	// showInitDialog bool
+	// initDialog     dialog.InitDialogCmp
+	//
+	// showFilepicker bool
+	// filepicker     dialog.FilepickerCmp
+	//
+	// showThemeDialog bool
+	// themeDialog     dialog.ThemeDialog
+	//
+	// showMultiArgumentsDialog bool
+	// multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
+	//
+	// isCompacting      bool
+	// compactingMessage string
+
+	// NEW DIALOG
+	dialog dialogs.DialogCmp
 }
 
 func (a appModel) Init() tea.Cmd {
 	var cmds []tea.Cmd
 	cmd := a.pages[a.currentPage].Init()
-	t := theme.CurrentTheme()
-	cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
-	a.loadedPages[a.currentPage] = true
 	cmds = append(cmds, cmd)
+	a.loadedPages[a.currentPage] = true
+
 	cmd = a.status.Init()
 	cmds = append(cmds, cmd)
-	cmd = a.quit.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.help.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.sessionDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.commandDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.modelDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.initDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.filepicker.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.themeDialog.Init()
-	cmds = append(cmds, cmd)
+	// cmd = a.help.Init()
+	// cmds = append(cmds, cmd)
+	// cmd = a.sessionDialog.Init()
+	// cmds = append(cmds, cmd)
+	// cmd = a.commandDialog.Init()
+	// cmds = append(cmds, cmd)
+	// cmd = a.modelDialog.Init()
+	// cmds = append(cmds, cmd)
+	// cmd = a.initDialog.Init()
+	// cmds = append(cmds, cmd)
+	// cmd = a.filepicker.Init()
+	// cmds = append(cmds, cmd)
+	// cmd = a.themeDialog.Init()
+	// cmds = append(cmds, cmd)
 
 	// Check if we should show the init dialog
-	cmds = append(cmds, func() tea.Msg {
-		shouldShow, err := config.ShouldShowInitDialog()
-		if err != nil {
-			return util.InfoMsg{
-				Type: util.InfoTypeError,
-				Msg:  "Failed to check init status: " + err.Error(),
-			}
-		}
-		return dialog.ShowInitDialogMsg{Show: shouldShow}
-	})
+	// cmds = append(cmds, func() tea.Msg {
+	// 	shouldShow, err := config.ShouldShowInitDialog()
+	// 	if err != nil {
+	// 		return util.InfoMsg{
+	// 			Type: util.InfoTypeError,
+	// 			Msg:  "Failed to check init status: " + err.Error(),
+	// 		}
+	// 	}
+	// 	return dialog.ShowInitDialogMsg{Show: shouldShow}
+	// })
 
+	t := theme.CurrentTheme()
+	cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
 	return tea.Batch(cmds...)
 }
 
-func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
+
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		msg.Height -= 1 // Make space for the status bar
-		a.width, a.height = msg.Width, msg.Height
-
-		s, _ := a.status.Update(msg)
-		a.status = s.(core.StatusCmp)
-		updated, cmd := a.pages[a.currentPage].Update(msg)
-		a.pages[a.currentPage] = updated.(util.Model)
-		cmds = append(cmds, cmd)
-
-		prm, permCmd := a.permissions.Update(msg)
-		a.permissions = prm.(dialog.PermissionDialogCmp)
-		cmds = append(cmds, permCmd)
-
-		help, helpCmd := a.help.Update(msg)
-		a.help = help.(dialog.HelpCmp)
-		cmds = append(cmds, helpCmd)
-
-		session, sessionCmd := a.sessionDialog.Update(msg)
-		a.sessionDialog = session.(dialog.SessionDialog)
-		cmds = append(cmds, sessionCmd)
-
-		command, commandCmd := a.commandDialog.Update(msg)
-		a.commandDialog = command.(dialog.CommandDialog)
-		cmds = append(cmds, commandCmd)
-
-		filepicker, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = filepicker.(dialog.FilepickerCmp)
-		cmds = append(cmds, filepickerCmd)
-
-		a.initDialog.SetSize(msg.Width, msg.Height)
-
-		if a.showMultiArgumentsDialog {
-			a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
-			args, argsCmd := a.multiArgumentsDialog.Update(msg)
-			a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
-			cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
-		}
+		return a, a.handleWindowResize(msg)
+	// TODO: remove when refactor is done
+	// msg.Height -= 1 // Make space for the status bar
+	// a.width, a.height = msg.Width, msg.Height
+	//
+	// s, _ := a.status.Update(msg)
+	// a.status = s.(core.StatusCmp)
+	// updated, cmd := a.pages[a.currentPage].Update(msg)
+	// a.pages[a.currentPage] = updated.(util.Model)
+	// cmds = append(cmds, cmd)
+	//
+	// prm, permCmd := a.permissions.Update(msg)
+	// a.permissions = prm.(dialog.PermissionDialogCmp)
+	// cmds = append(cmds, permCmd)
+	//
+	// help, helpCmd := a.help.Update(msg)
+	// a.help = help.(dialog.HelpCmp)
+	// cmds = append(cmds, helpCmd)
+	//
+	// session, sessionCmd := a.sessionDialog.Update(msg)
+	// a.sessionDialog = session.(dialog.SessionDialog)
+	// cmds = append(cmds, sessionCmd)
+	//
+	// command, commandCmd := a.commandDialog.Update(msg)
+	// a.commandDialog = command.(dialog.CommandDialog)
+	// cmds = append(cmds, commandCmd)
+	//
+	// filepicker, filepickerCmd := a.filepicker.Update(msg)
+	// a.filepicker = filepicker.(dialog.FilepickerCmp)
+	// cmds = append(cmds, filepickerCmd)
+	//
+	// a.initDialog.SetSize(msg.Width, msg.Height)
+	//
+	// if a.showMultiArgumentsDialog {
+	// 	a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
+	// 	args, argsCmd := a.multiArgumentsDialog.Update(msg)
+	// 	a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
+	// 	cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
+	// }
+	//
+	// dialog, cmd := a.dialog.Update(msg)
+	// a.dialog = dialog.(dialogs.DialogCmp)
+	// cmds = append(cmds, cmd)
+	//
+	// return a, tea.Batch(cmds...)
+
+	// Dialog messages
+	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
+		u, dialogCmd := a.dialog.Update(msg)
+		a.dialog = u.(dialogs.DialogCmp)
+		return a, dialogCmd
+
+	// Page change messages
+	case page.PageChangeMsg:
+		return a, a.moveToPage(msg.ID)
 
-		return a, tea.Batch(cmds...)
-	// Status
-	case util.InfoMsg:
+	// Status Messages
+	case util.InfoMsg, util.ClearStatusMsg:
 		s, cmd := a.status.Update(msg)
 		a.status = s.(core.StatusCmp)
 		cmds = append(cmds, cmd)
 		return a, tea.Batch(cmds...)
+	// Logs
 	case pubsub.Event[logging.LogMessage]:
-		if msg.Payload.Persist {
-			switch msg.Payload.Level {
-			case "error":
-				s, cmd := a.status.Update(util.InfoMsg{
-					Type: util.InfoTypeError,
-					Msg:  msg.Payload.Message,
-					TTL:  msg.Payload.PersistTime,
-				})
-				a.status = s.(core.StatusCmp)
-				cmds = append(cmds, cmd)
-			case "info":
-				s, cmd := a.status.Update(util.InfoMsg{
-					Type: util.InfoTypeInfo,
-					Msg:  msg.Payload.Message,
-					TTL:  msg.Payload.PersistTime,
-				})
-				a.status = s.(core.StatusCmp)
-				cmds = append(cmds, cmd)
-
-			case "warn":
-				s, cmd := a.status.Update(util.InfoMsg{
-					Type: util.InfoTypeWarn,
-					Msg:  msg.Payload.Message,
-					TTL:  msg.Payload.PersistTime,
-				})
-
-				a.status = s.(core.StatusCmp)
-				cmds = append(cmds, cmd)
-			default:
-				s, cmd := a.status.Update(util.InfoMsg{
-					Type: util.InfoTypeInfo,
-					Msg:  msg.Payload.Message,
-					TTL:  msg.Payload.PersistTime,
-				})
-				a.status = s.(core.StatusCmp)
-				cmds = append(cmds, cmd)
-			}
-		}
-	case util.ClearStatusMsg:
-		s, _ := a.status.Update(msg)
+		// Send to the status component
+		s, cmd := a.status.Update(msg)
 		a.status = s.(core.StatusCmp)
+		cmds = append(cmds, cmd)
 
-	// Permission
-	case pubsub.Event[permission.PermissionRequest]:
-		a.showPermissions = true
-		return a, a.permissions.SetPermissions(msg.Payload)
-	case dialog.PermissionResponseMsg:
-		var cmd tea.Cmd
-		switch msg.Action {
-		case dialog.PermissionAllow:
-			a.app.Permissions.Grant(msg.Permission)
-		case dialog.PermissionAllowForSession:
-			a.app.Permissions.GrantPersistant(msg.Permission)
-		case dialog.PermissionDeny:
-			a.app.Permissions.Deny(msg.Permission)
+		// If the current page is logs, update the logs view
+		if a.currentPage == page.LogsPage {
+			updated, cmd := a.pages[a.currentPage].Update(msg)
+			a.pages[a.currentPage] = updated.(util.Model)
+			cmds = append(cmds, cmd)
 		}
-		a.showPermissions = false
-		return a, cmd
-
-	case page.PageChangeMsg:
-		return a, a.moveToPage(msg.ID)
-
-	case dialog.CloseQuitMsg:
-		a.showQuit = false
-		return a, nil
-
-	case dialog.CloseSessionDialogMsg:
-		a.showSessionDialog = false
-		return a, nil
-
-	case dialog.CloseCommandDialogMsg:
-		a.showCommandDialog = false
-		return a, nil
-
-	case startCompactSessionMsg:
-		// Start compacting the current session
-		a.isCompacting = true
-		a.compactingMessage = "Starting summarization..."
+		return a, tea.Batch(cmds...)
 
-		if a.selectedSession.ID == "" {
-			a.isCompacting = false
-			return a, util.ReportWarn("No active session to summarize")
-		}
+	// // Permission
+	// case pubsub.Event[permission.PermissionRequest]:
+	// 	a.showPermissions = true
+	// 	return a, a.permissions.SetPermissions(msg.Payload)
+	// case dialog.PermissionResponseMsg:
+	// 	var cmd tea.Cmd
+	// 	switch msg.Action {
+	// 	case dialog.PermissionAllow:
+	// 		a.app.Permissions.Grant(msg.Permission)
+	// 	case dialog.PermissionAllowForSession:
+	// 		a.app.Permissions.GrantPersistant(msg.Permission)
+	// 	case dialog.PermissionDeny:
+	// 		a.app.Permissions.Deny(msg.Permission)
+	// 	}
+	// 	a.showPermissions = false
+	// 	return a, cmd
+	//
+	// 	// Theme changed
+	// case dialog.ThemeChangedMsg:
+	// 	updated, cmd := a.pages[a.currentPage].Update(msg)
+	// 	a.pages[a.currentPage] = updated.(util.Model)
+	// 	a.showThemeDialog = false
+	// 	return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
+	//
+	// case dialog.CloseSessionDialogMsg:
+	// 	a.showSessionDialog = false
+	// 	return a, nil
+	//
+	// case dialog.CloseCommandDialogMsg:
+	// 	a.showCommandDialog = false
+	// 	return a, nil
+	//
+	// case startCompactSessionMsg:
+	// 	// Start compacting the current session
+	// 	a.isCompacting = true
+	// 	a.compactingMessage = "Starting summarization..."
+	//
+	// 	if a.selectedSession.ID == "" {
+	// 		a.isCompacting = false
+	// 		return a, util.ReportWarn("No active session to summarize")
+	// 	}
+	//
+	// 	// Start the summarization process
+	// 	return a, func() tea.Msg {
+	// 		ctx := context.Background()
+	// 		a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
+	// 		return nil
+	// 	}
+	//
+	// case pubsub.Event[agent.AgentEvent]:
+	// 	payload := msg.Payload
+	// 	if payload.Error != nil {
+	// 		a.isCompacting = false
+	// 		return a, util.ReportError(payload.Error)
+	// 	}
+	//
+	// 	a.compactingMessage = payload.Progress
+	//
+	// 	if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
+	// 		a.isCompacting = false
+	// 		return a, util.ReportInfo("Session summarization complete")
+	// 	} else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
+	// 		model := a.app.CoderAgent.Model()
+	// 		contextWindow := model.ContextWindow
+	// 		tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
+	// 		if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
+	// 			return a, util.CmdHandler(startCompactSessionMsg{})
+	// 		}
+	// 	}
+	// 	// Continue listening for events
+	// 	return a, nil
+	//
+	// case dialog.CloseThemeDialogMsg:
+	// 	a.showThemeDialog = false
+	// 	return a, nil
+	//
+	// case dialog.CloseModelDialogMsg:
+	// 	a.showModelDialog = false
+	// 	return a, nil
+	//
+	// case dialog.ModelSelectedMsg:
+	// 	a.showModelDialog = false
+	//
+	// 	model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
+	// 	if err != nil {
+	// 		return a, util.ReportError(err)
+	// 	}
+	//
+	// 	return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
+	//
+	// case dialog.ShowInitDialogMsg:
+	// 	a.showInitDialog = msg.Show
+	// 	return a, nil
+	//
+	// case dialog.CloseInitDialogMsg:
+	// 	a.showInitDialog = false
+	// 	if msg.Initialize {
+	// 		// Run the initialization command
+	// 		for _, cmd := range a.commands {
+	// 			if cmd.ID == "init" {
+	// 				// Mark the project as initialized
+	// 				if err := config.MarkProjectInitialized(); err != nil {
+	// 					return a, util.ReportError(err)
+	// 				}
+	// 				return a, cmd.Handler(cmd)
+	// 			}
+	// 		}
+	// 	} else {
+	// 		// Mark the project as initialized without running the command
+	// 		if err := config.MarkProjectInitialized(); err != nil {
+	// 			return a, util.ReportError(err)
+	// 		}
+	// 	}
+	// 	return a, nil
+	//
+	// case chat.SessionSelectedMsg:
+	// 	a.selectedSession = msg
+	// 	a.sessionDialog.SetSelectedSession(msg.ID)
+	//
+	// case pubsub.Event[session.Session]:
+	// 	if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
+	// 		a.selectedSession = msg.Payload
+	// 	}
+	// case dialog.SessionSelectedMsg:
+	// 	a.showSessionDialog = false
+	// 	if a.currentPage == page.ChatPage {
+	// 		return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
+	// 	}
+	// 	return a, nil
+	//
+	// case dialog.CommandSelectedMsg:
+	// 	a.showCommandDialog = false
+	// 	// Execute the command handler if available
+	// 	if msg.Command.Handler != nil {
+	// 		return a, msg.Command.Handler(msg.Command)
+	// 	}
+	// 	return a, util.ReportInfo("Command selected: " + msg.Command.Title)
+	//
+	// case dialog.ShowMultiArgumentsDialogMsg:
+	// 	// Show multi-arguments dialog
+	// 	a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
+	// 	a.showMultiArgumentsDialog = true
+	// 	return a, a.multiArgumentsDialog.Init()
+	//
+	// case dialog.CloseMultiArgumentsDialogMsg:
+	// 	// Close multi-arguments dialog
+	// 	a.showMultiArgumentsDialog = false
+	//
+	// 	// If submitted, replace all named arguments and run the command
+	// 	if msg.Submit {
+	// 		content := msg.Content
+	//
+	// 		// Replace each named argument with its value
+	// 		for name, value := range msg.Args {
+	// 			placeholder := "$" + name
+	// 			content = strings.ReplaceAll(content, placeholder, value)
+	// 		}
+	//
+	// 		// Execute the command with arguments
+	// 		return a, util.CmdHandler(dialog.CommandRunCustomMsg{
+	// 			Content: content,
+	// 			Args:    msg.Args,
+	// 		})
+	// 	}
+	// 	return a, nil
+	//
+	case tea.KeyPressMsg:
+		return a, a.handleKeyPressMsg(msg)
+		// if a.dialog.HasDialogs() {
+		// 	u, dialogCmd := a.dialog.Update(msg)
+		// 	a.dialog = u.(dialogs.DialogCmp)
+		// 	return a, dialogCmd
+		// }
+		// // If multi-arguments dialog is open, let it handle the key press first
+		// if a.showMultiArgumentsDialog {
+		// 	args, cmd := a.multiArgumentsDialog.Update(msg)
+		// 	a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
+		// 	return a, cmd
+		// }
+		//
+		// switch {
+		// case key.Matches(msg, keys.Quit):
+		// 	// TODO: fix this after testing
+		// 	// a.showQuit = !a.showQuit
+		// 	// if a.showHelp {
+		// 	// 	a.showHelp = false
+		// 	// }
+		// 	// if a.showSessionDialog {
+		// 	// 	a.showSessionDialog = false
+		// 	// }
+		// 	// if a.showCommandDialog {
+		// 	// 	a.showCommandDialog = false
+		// 	// }
+		// 	// if a.showFilepicker {
+		// 	// 	a.showFilepicker = false
+		// 	// 	a.filepicker.ToggleFilepicker(a.showFilepicker)
+		// 	// }
+		// 	// if a.showModelDialog {
+		// 	// 	a.showModelDialog = false
+		// 	// }
+		// 	// if a.showMultiArgumentsDialog {
+		// 	// 	a.showMultiArgumentsDialog = false
+		// 	// }
+		// 	return a, util.CmdHandler(dialogs.OpenDialogMsg{
+		// 		Model: quit.NewQuitDialog(),
+		// 	})
+		// case key.Matches(msg, keys.SwitchSession):
+		// 	if a.currentPage == page.ChatPage && !a.showPermissions && !a.showCommandDialog {
+		// 		// Load sessions and show the dialog
+		// 		sessions, err := a.app.Sessions.List(context.Background())
+		// 		if err != nil {
+		// 			return a, util.ReportError(err)
+		// 		}
+		// 		if len(sessions) == 0 {
+		// 			return a, util.ReportWarn("No sessions available")
+		// 		}
+		// 		a.sessionDialog.SetSessions(sessions)
+		// 		a.showSessionDialog = true
+		// 		return a, nil
+		// 	}
+		// 	return a, nil
+		// case key.Matches(msg, keys.Commands):
+		// if a.currentPage == page.ChatPage && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
+		// 	// Show commands dialog
+		// 	if len(a.commands) == 0 {
+		// 		return a, util.ReportWarn("No commands available")
+		// 	}
+		// 	a.commandDialog.SetCommands(a.commands)
+		// 	a.showCommandDialog = true
+		// 	return a, nil
+		// }
+		// 	return a, util.CmdHandler(dialogs.OpenDialogMsg{
+		// 		Model: commands.NewCommandDialog(),
+		// 	})
+		// case key.Matches(msg, keys.Models):
+		// 	if a.showModelDialog {
+		// 		a.showModelDialog = false
+		// 		return a, nil
+		// 	}
+		// 	if a.currentPage == page.ChatPage && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+		// 		a.showModelDialog = true
+		// 		return a, nil
+		// 	}
+		// 	return a, nil
+		// case key.Matches(msg, keys.SwitchTheme):
+		// 	if !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+		// 		// Show theme switcher dialog
+		// 		a.showThemeDialog = true
+		// 		// Theme list is dynamically loaded by the dialog component
+		// 		return a, a.themeDialog.Init()
+		// 	}
+		// 	return a, nil
+		// case key.Matches(msg, returnKey) || key.Matches(msg):
+		// 	if msg.String() == quitKey {
+		// 		if a.currentPage == page.LogsPage {
+		// 			return a, a.moveToPage(page.ChatPage)
+		// 		}
+		// 	} else if !a.filepicker.IsCWDFocused() {
+		// 		if a.showHelp {
+		// 			a.showHelp = !a.showHelp
+		// 			return a, nil
+		// 		}
+		// 		if a.showInitDialog {
+		// 			a.showInitDialog = false
+		// 			// Mark the project as initialized without running the command
+		// 			if err := config.MarkProjectInitialized(); err != nil {
+		// 				return a, util.ReportError(err)
+		// 			}
+		// 			return a, nil
+		// 		}
+		// 		if a.showFilepicker {
+		// 			a.showFilepicker = false
+		// 			a.filepicker.ToggleFilepicker(a.showFilepicker)
+		// 			return a, nil
+		// 		}
+		// 		if a.currentPage == page.LogsPage {
+		// 			return a, a.moveToPage(page.ChatPage)
+		// 		}
+		// 	}
+		// case key.Matches(msg, keys.Logs):
+		// 	return a, a.moveToPage(page.LogsPage)
+		// case key.Matches(msg, keys.Help):
+		// 	a.showHelp = !a.showHelp
+		// 	return a, nil
+		// case key.Matches(msg, helpEsc):
+		// 	if a.app.CoderAgent.IsBusy() {
+		// 		a.showHelp = !a.showHelp
+		// 		return a, nil
+		// 	}
+		// case key.Matches(msg, keys.Filepicker):
+		// 	a.showFilepicker = !a.showFilepicker
+		// 	a.filepicker.ToggleFilepicker(a.showFilepicker)
+		// 	return a, nil
+		// }
+		// default:
+		// 	u, dialogCmd := a.dialog.Update(msg)
+		// 	a.dialog = u.(dialogs.DialogCmp)
+		// 	cmds = append(cmds, dialogCmd)
+		// f, filepickerCmd := a.filepicker.Update(msg)
+		// a.filepicker = f.(dialog.FilepickerCmp)
+		// cmds = append(cmds, filepickerCmd)
+		// }
+
+		// if a.showFilepicker {
+		// 	f, filepickerCmd := a.filepicker.Update(msg)
+		// 	a.filepicker = f.(dialog.FilepickerCmp)
+		// 	cmds = append(cmds, filepickerCmd)
+		// 	// Only block key messages send all other messages down
+		// 	if _, ok := msg.(tea.KeyPressMsg); ok {
+		// 		return a, tea.Batch(cmds...)
+		// 	}
+		// }
+		//
+		// if a.showPermissions {
+		// 	d, permissionsCmd := a.permissions.Update(msg)
+		// 	a.permissions = d.(dialog.PermissionDialogCmp)
+		// 	cmds = append(cmds, permissionsCmd)
+		// 	// Only block key messages send all other messages down
+		// 	if _, ok := msg.(tea.KeyPressMsg); ok {
+		// 		return a, tea.Batch(cmds...)
+		// 	}
+		// }
+		//
+		// if a.showSessionDialog {
+		// 	d, sessionCmd := a.sessionDialog.Update(msg)
+		// 	a.sessionDialog = d.(dialog.SessionDialog)
+		// 	cmds = append(cmds, sessionCmd)
+		// 	// Only block key messages send all other messages down
+		// 	if _, ok := msg.(tea.KeyPressMsg); ok {
+		// 		return a, tea.Batch(cmds...)
+		// 	}
+		// }
+		//
+		// if a.showCommandDialog {
+		// 	d, commandCmd := a.commandDialog.Update(msg)
+		// 	a.commandDialog = d.(dialog.CommandDialog)
+		// 	cmds = append(cmds, commandCmd)
+		// 	// Only block key messages send all other messages down
+		// 	if _, ok := msg.(tea.KeyPressMsg); ok {
+		// 		return a, tea.Batch(cmds...)
+		// 	}
+		// }
+		//
+		// if a.showModelDialog {
+		// 	d, modelCmd := a.modelDialog.Update(msg)
+		// 	a.modelDialog = d.(dialog.ModelDialog)
+		// 	cmds = append(cmds, modelCmd)
+		// 	// Only block key messages send all other messages down
+		// 	if _, ok := msg.(tea.KeyPressMsg); ok {
+		// 		return a, tea.Batch(cmds...)
+		// 	}
+		// }
+		//
+		// if a.showInitDialog {
+		// 	d, initCmd := a.initDialog.Update(msg)
+		// 	a.initDialog = d.(dialog.InitDialogCmp)
+		// 	cmds = append(cmds, initCmd)
+		// 	// Only block key messages send all other messages down
+		// 	if _, ok := msg.(tea.KeyPressMsg); ok {
+		// 		return a, tea.Batch(cmds...)
+		// 	}
+		// }
+		//
+		// if a.showThemeDialog {
+		// 	d, themeCmd := a.themeDialog.Update(msg)
+		// 	a.themeDialog = d.(dialog.ThemeDialog)
+		// 	cmds = append(cmds, themeCmd)
+		// 	// Only block key messages send all other messages down
+		// 	if _, ok := msg.(tea.KeyPressMsg); ok {
+		// 		return a, tea.Batch(cmds...)
+		// 	}
+	}
+	//
+	s, _ := a.status.Update(msg)
+	a.status = s.(core.StatusCmp)
+	updated, cmd := a.pages[a.currentPage].Update(msg)
+	a.pages[a.currentPage] = updated.(util.Model)
+	cmds = append(cmds, cmd)
+	return a, tea.Batch(cmds...)
+}
 
-		// Start the summarization process
-		return a, func() tea.Msg {
-			ctx := context.Background()
-			a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
-			return nil
-		}
+func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+	var cmds []tea.Cmd
+	msg.Height -= 1 // Make space for the status bar
+	a.width, a.height = msg.Width, msg.Height
 
-	case pubsub.Event[agent.AgentEvent]:
-		payload := msg.Payload
-		if payload.Error != nil {
-			a.isCompacting = false
-			return a, util.ReportError(payload.Error)
-		}
+	// Update status bar
+	s, cmd := a.status.Update(msg)
+	a.status = s.(core.StatusCmp)
+	cmds = append(cmds, cmd)
 
-		a.compactingMessage = payload.Progress
-
-		if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
-			a.isCompacting = false
-			return a, util.ReportInfo("Session summarization complete")
-		} else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
-			model := a.app.CoderAgent.Model()
-			contextWindow := model.ContextWindow
-			tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
-			if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
-				return a, util.CmdHandler(startCompactSessionMsg{})
-			}
-		}
-		// Continue listening for events
-		return a, nil
-
-	case dialog.CloseThemeDialogMsg:
-		a.showThemeDialog = false
-		return a, nil
-
-	case dialog.ThemeChangedMsg:
-		updated, cmd := a.pages[a.currentPage].Update(msg)
-		a.pages[a.currentPage] = updated.(util.Model)
-		a.showThemeDialog = false
-		t := theme.CurrentTheme()
-		return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName), tea.SetBackgroundColor(t.Background()))
-
-	case dialog.CloseModelDialogMsg:
-		a.showModelDialog = false
-		return a, nil
-
-	case dialog.ModelSelectedMsg:
-		a.showModelDialog = false
-
-		model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
-		if err != nil {
-			return a, util.ReportError(err)
-		}
+	// Update the current page
+	updated, cmd := a.pages[a.currentPage].Update(msg)
+	a.pages[a.currentPage] = updated.(util.Model)
+	cmds = append(cmds, cmd)
 
-		return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
-
-	case dialog.ShowInitDialogMsg:
-		a.showInitDialog = msg.Show
-		return a, nil
-
-	case dialog.CloseInitDialogMsg:
-		a.showInitDialog = false
-		if msg.Initialize {
-			// Run the initialization command
-			for _, cmd := range a.commands {
-				if cmd.ID == "init" {
-					// Mark the project as initialized
-					if err := config.MarkProjectInitialized(); err != nil {
-						return a, util.ReportError(err)
-					}
-					return a, cmd.Handler(cmd)
-				}
-			}
-		} else {
-			// Mark the project as initialized without running the command
-			if err := config.MarkProjectInitialized(); err != nil {
-				return a, util.ReportError(err)
-			}
-		}
-		return a, nil
+	// Update the dialogs
+	dialog, cmd := a.dialog.Update(msg)
+	a.dialog = dialog.(dialogs.DialogCmp)
+	cmds = append(cmds, cmd)
 
-	case chat.SessionSelectedMsg:
-		a.selectedSession = msg
-		a.sessionDialog.SetSelectedSession(msg.ID)
+	return tea.Batch(cmds...)
+}
 
-	case pubsub.Event[session.Session]:
-		if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
-			a.selectedSession = msg.Payload
-		}
-	case dialog.SessionSelectedMsg:
-		a.showSessionDialog = false
-		if a.currentPage == page.ChatPage {
-			return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
-		}
-		return a, nil
+func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+	switch {
+	// dialogs
+	case key.Matches(msg, a.keyMap.Quit):
+		return util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: quit.NewQuitDialog(),
+		})
 
-	case dialog.CommandSelectedMsg:
-		a.showCommandDialog = false
-		// Execute the command handler if available
-		if msg.Command.Handler != nil {
-			return a, msg.Command.Handler(msg.Command)
-		}
-		return a, util.ReportInfo("Command selected: " + msg.Command.Title)
-
-	case dialog.ShowMultiArgumentsDialogMsg:
-		// Show multi-arguments dialog
-		a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
-		a.showMultiArgumentsDialog = true
-		return a, a.multiArgumentsDialog.Init()
-
-	case dialog.CloseMultiArgumentsDialogMsg:
-		// Close multi-arguments dialog
-		a.showMultiArgumentsDialog = false
-
-		// If submitted, replace all named arguments and run the command
-		if msg.Submit {
-			content := msg.Content
-
-			// Replace each named argument with its value
-			for name, value := range msg.Args {
-				placeholder := "$" + name
-				content = strings.ReplaceAll(content, placeholder, value)
-			}
-
-			// Execute the command with arguments
-			return a, util.CmdHandler(dialog.CommandRunCustomMsg{
-				Content: content,
-				Args:    msg.Args,
-			})
-		}
-		return a, nil
+	case key.Matches(msg, a.keyMap.Commands):
+		return util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: commands.NewCommandDialog(),
+		})
 
-	case tea.KeyPressMsg:
-		// If multi-arguments dialog is open, let it handle the key press first
-		if a.showMultiArgumentsDialog {
-			args, cmd := a.multiArgumentsDialog.Update(msg)
-			a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
-			return a, cmd
-		}
+	// Page navigation
+	case key.Matches(msg, a.keyMap.Logs):
+		return a.moveToPage(page.LogsPage)
 
-		switch {
-		case key.Matches(msg, keys.Quit):
-			a.showQuit = !a.showQuit
-			if a.showHelp {
-				a.showHelp = false
-			}
-			if a.showSessionDialog {
-				a.showSessionDialog = false
-			}
-			if a.showCommandDialog {
-				a.showCommandDialog = false
-			}
-			if a.showFilepicker {
-				a.showFilepicker = false
-				a.filepicker.ToggleFilepicker(a.showFilepicker)
-			}
-			if a.showModelDialog {
-				a.showModelDialog = false
-			}
-			if a.showMultiArgumentsDialog {
-				a.showMultiArgumentsDialog = false
-			}
-			return a, nil
-		case key.Matches(msg, keys.SwitchSession):
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
-				// Load sessions and show the dialog
-				sessions, err := a.app.Sessions.List(context.Background())
-				if err != nil {
-					return a, util.ReportError(err)
-				}
-				if len(sessions) == 0 {
-					return a, util.ReportWarn("No sessions available")
-				}
-				a.sessionDialog.SetSessions(sessions)
-				a.showSessionDialog = true
-				return a, nil
-			}
-			return a, nil
-		case key.Matches(msg, keys.Commands):
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
-				// Show commands dialog
-				if len(a.commands) == 0 {
-					return a, util.ReportWarn("No commands available")
-				}
-				a.commandDialog.SetCommands(a.commands)
-				a.showCommandDialog = true
-				return a, nil
-			}
-			return a, nil
-		case key.Matches(msg, keys.Models):
-			if a.showModelDialog {
-				a.showModelDialog = false
-				return a, nil
-			}
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
-				a.showModelDialog = true
-				return a, nil
-			}
-			return a, nil
-		case key.Matches(msg, keys.SwitchTheme):
-			if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
-				// Show theme switcher dialog
-				a.showThemeDialog = true
-				// Theme list is dynamically loaded by the dialog component
-				return a, a.themeDialog.Init()
-			}
-			return a, nil
-		case key.Matches(msg, returnKey) || key.Matches(msg):
-			if msg.String() == quitKey {
-				if a.currentPage == page.LogsPage {
-					return a, a.moveToPage(page.ChatPage)
-				}
-			} else if !a.filepicker.IsCWDFocused() {
-				if a.showQuit {
-					a.showQuit = !a.showQuit
-					return a, nil
-				}
-				if a.showHelp {
-					a.showHelp = !a.showHelp
-					return a, nil
-				}
-				if a.showInitDialog {
-					a.showInitDialog = false
-					// Mark the project as initialized without running the command
-					if err := config.MarkProjectInitialized(); err != nil {
-						return a, util.ReportError(err)
-					}
-					return a, nil
-				}
-				if a.showFilepicker {
-					a.showFilepicker = false
-					a.filepicker.ToggleFilepicker(a.showFilepicker)
-					return a, nil
-				}
-				if a.currentPage == page.LogsPage {
-					return a, a.moveToPage(page.ChatPage)
-				}
-			}
-		case key.Matches(msg, keys.Logs):
-			return a, a.moveToPage(page.LogsPage)
-		case key.Matches(msg, keys.Help):
-			if a.showQuit {
-				return a, nil
-			}
-			a.showHelp = !a.showHelp
-			return a, nil
-		case key.Matches(msg, helpEsc):
-			if a.app.CoderAgent.IsBusy() {
-				if a.showQuit {
-					return a, nil
-				}
-				a.showHelp = !a.showHelp
-				return a, nil
-			}
-		case key.Matches(msg, keys.Filepicker):
-			a.showFilepicker = !a.showFilepicker
-			a.filepicker.ToggleFilepicker(a.showFilepicker)
-			return a, nil
-		}
 	default:
-		f, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = f.(dialog.FilepickerCmp)
-		cmds = append(cmds, filepickerCmd)
-	}
-
-	if a.showFilepicker {
-		f, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = f.(dialog.FilepickerCmp)
-		cmds = append(cmds, filepickerCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyPressMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showQuit {
-		q, quitCmd := a.quit.Update(msg)
-		a.quit = q.(dialog.QuitDialog)
-		cmds = append(cmds, quitCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyPressMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-	if a.showPermissions {
-		d, permissionsCmd := a.permissions.Update(msg)
-		a.permissions = d.(dialog.PermissionDialogCmp)
-		cmds = append(cmds, permissionsCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyPressMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showSessionDialog {
-		d, sessionCmd := a.sessionDialog.Update(msg)
-		a.sessionDialog = d.(dialog.SessionDialog)
-		cmds = append(cmds, sessionCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyPressMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showCommandDialog {
-		d, commandCmd := a.commandDialog.Update(msg)
-		a.commandDialog = d.(dialog.CommandDialog)
-		cmds = append(cmds, commandCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyPressMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showModelDialog {
-		d, modelCmd := a.modelDialog.Update(msg)
-		a.modelDialog = d.(dialog.ModelDialog)
-		cmds = append(cmds, modelCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyPressMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showInitDialog {
-		d, initCmd := a.initDialog.Update(msg)
-		a.initDialog = d.(dialog.InitDialogCmp)
-		cmds = append(cmds, initCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyPressMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showThemeDialog {
-		d, themeCmd := a.themeDialog.Update(msg)
-		a.themeDialog = d.(dialog.ThemeDialog)
-		cmds = append(cmds, themeCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyPressMsg); ok {
-			return a, tea.Batch(cmds...)
+		if a.dialog.HasDialogs() {
+			u, dialogCmd := a.dialog.Update(msg)
+			a.dialog = u.(dialogs.DialogCmp)
+			return dialogCmd
+		} else {
+			updated, cmd := a.pages[a.currentPage].Update(msg)
+			a.pages[a.currentPage] = updated.(util.Model)
+			return cmd
 		}
 	}
-
-	s, _ := a.status.Update(msg)
-	a.status = s.(core.StatusCmp)
-	updated, cmd := a.pages[a.currentPage].Update(msg)
-	a.pages[a.currentPage] = updated.(util.Model)
-	cmds = append(cmds, cmd)
-	return a, tea.Batch(cmds...)
 }
 
 // RegisterCommand adds a command to the command dialog
-func (a *appModel) RegisterCommand(cmd dialog.Command) {
-	a.commands = append(a.commands, cmd)
-}
+// func (a *appModel) RegisterCommand(cmd dialog.Command) {
+// 	a.commands = append(a.commands, cmd)
+// }
 
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	if a.app.CoderAgent.IsBusy() {

internal/tui/util/util.go 🔗

@@ -8,7 +8,7 @@ import (
 
 type Model interface {
 	tea.Model
-	tea.ViewModel
+	tea.Viewable
 }
 
 func CmdHandler(msg tea.Msg) tea.Cmd {