refactor(ui): dialog: message and draw handling (#1822)

Ayman Bagabas created

Change summary

internal/ui/dialog/actions.go       |  48 +++++
internal/ui/dialog/commands.go      |  93 ++++++-----
internal/ui/dialog/dialog.go        |  47 +++--
internal/ui/dialog/messages.go      |  44 -----
internal/ui/dialog/models.go        |  59 +++---
internal/ui/dialog/quit.go          |  27 +-
internal/ui/dialog/sessions.go      |  67 ++++---
internal/ui/dialog/sessions_item.go |  10 
internal/ui/model/ui.go             | 259 ++++++++++++++++--------------
9 files changed, 348 insertions(+), 306 deletions(-)

Detailed changes

internal/ui/dialog/actions.go 🔗

@@ -0,0 +1,48 @@
+package dialog
+
+import (
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+// ActionClose is a message to close the current dialog.
+type ActionClose struct{}
+
+// ActionQuit is a message to quit the application.
+type ActionQuit = tea.QuitMsg
+
+// ActionOpenDialog is a message to open a dialog.
+type ActionOpenDialog struct {
+	DialogID string
+}
+
+// ActionSelectSession is a message indicating a session has been selected.
+type ActionSelectSession struct {
+	Session session.Session
+}
+
+// ActionSelectModel is a message indicating a model has been selected.
+type ActionSelectModel struct {
+	Model     config.SelectedModel
+	ModelType config.SelectedModelType
+}
+
+// Messages for commands
+type (
+	ActionNewSession        struct{}
+	ActionToggleHelp        struct{}
+	ActionToggleCompactMode struct{}
+	ActionToggleThinking    struct{}
+	ActionExternalEditor    struct{}
+	ActionToggleYoloMode    struct{}
+	ActionSummarize         struct {
+		SessionID string
+	}
+)
+
+// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
+// Bubble Tea program loop.
+type ActionCmd struct {
+	Cmd tea.Cmd
+}

internal/ui/dialog/commands.go 🔗

@@ -21,6 +21,8 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/uicmd"
 	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
 )
 
 // CommandsID is the identifier for the commands dialog.
@@ -43,10 +45,11 @@ type Commands struct {
 	userCmds   []uicmd.Command
 	mcpPrompts *csync.Slice[uicmd.Command]
 
-	help          help.Model
-	input         textinput.Model
-	list          *list.FilterableList
-	width, height int
+	help  help.Model
+	input textinput.Model
+	list  *list.FilterableList
+
+	width int
 }
 
 var _ Dialog = (*Commands)(nil)
@@ -114,33 +117,18 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
 	return c, nil
 }
 
-// SetSize sets the size of the dialog.
-func (c *Commands) SetSize(width, height int) {
-	t := c.com.Styles
-	c.width = width
-	c.height = height
-	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
-	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
-		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
-		t.Dialog.HelpView.GetVerticalFrameSize() +
-		t.Dialog.View.GetVerticalFrameSize()
-	c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
-	c.list.SetSize(innerWidth, height-heightOffset)
-	c.help.SetWidth(width)
-}
-
 // ID implements Dialog.
 func (c *Commands) ID() string {
 	return CommandsID
 }
 
-// Update implements Dialog.
-func (c *Commands) Update(msg tea.Msg) tea.Msg {
+// HandleMsg implements Dialog.
+func (c *Commands) HandleMsg(msg tea.Msg) Action {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, c.keyMap.Close):
-			return CloseMsg{}
+			return ActionClose{}
 		case key.Matches(msg, c.keyMap.Previous):
 			c.list.Focus()
 			if c.list.IsSelectedFirst() {
@@ -181,9 +169,7 @@ func (c *Commands) Update(msg tea.Msg) tea.Msg {
 			c.list.SetFilter(value)
 			c.list.ScrollToTop()
 			c.list.SetSelected(0)
-			if cmd != nil {
-				return cmd()
-			}
+			return ActionCmd{cmd}
 		}
 	}
 	return nil
@@ -231,16 +217,35 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
 	return strings.Join(parts, " ")
 }
 
-// View implements [Dialog].
-func (c *Commands) View() string {
+// Draw implements [Dialog].
+func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := c.com.Styles
+	width := max(0, min(100, area.Dx()))
+	height := max(0, min(30, area.Dy()))
+	c.width = width
+	// TODO: Why do we need this 2?
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		// TODO: Why do we need this 2?
+		t.Dialog.View.GetVerticalFrameSize() + 2
+	c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	c.list.SetSize(innerWidth, height-heightOffset)
+	c.help.SetWidth(innerWidth)
+
 	radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
 	titleStyle := t.Dialog.Title
-	dialogStyle := t.Dialog.View.Width(c.width)
+	dialogStyle := t.Dialog.View.Width(width)
 	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
-	header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
-	return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
-		c.input.View(), c.list.Render(), c.help.View(c))
+	helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
+	header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
+	view := HeaderInputListHelpView(t, width, c.list.Height(), header,
+		c.input.View(), c.list.Render(), helpView)
+
+	cur := c.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
 }
 
 // ShortHelp implements [help.KeyMap].
@@ -318,7 +323,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			Description: "start a new session",
 			Shortcut:    "ctrl+n",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(NewSessionsMsg{})
+				return uiutil.CmdHandler(ActionNewSession{})
 			},
 		},
 		{
@@ -327,7 +332,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			Description: "Switch to a different session",
 			Shortcut:    "ctrl+s",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(OpenDialogMsg{SessionsID})
+				return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
 			},
 		},
 		{
@@ -337,7 +342,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			// FIXME: The shortcut might get updated if enhanced keyboard is supported.
 			Shortcut: "ctrl+l",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(OpenDialogMsg{ModelsID})
+				return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
 			},
 		},
 	}
@@ -349,7 +354,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			Title:       "Summarize Session",
 			Description: "Summarize the current session and create a new one with the summary",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(CompactMsg{
+				return uiutil.CmdHandler(ActionSummarize{
 					SessionID: c.sessionID,
 				})
 			},
@@ -375,7 +380,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 					Title:       status + " Thinking Mode",
 					Description: "Toggle model thinking for reasoning-capable models",
 					Handler: func(cmd uicmd.Command) tea.Cmd {
-						return uiutil.CmdHandler(ToggleThinkingMsg{})
+						return uiutil.CmdHandler(ActionToggleThinking{})
 					},
 				})
 			}
@@ -387,7 +392,9 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 					Title:       "Select Reasoning Effort",
 					Description: "Choose reasoning effort level (low/medium/high)",
 					Handler: func(cmd uicmd.Command) tea.Cmd {
-						return uiutil.CmdHandler(OpenReasoningDialogMsg{})
+						return uiutil.CmdHandler(ActionOpenDialog{
+							// TODO: Pass reasoning dialog id
+						})
 					},
 				})
 			}
@@ -401,7 +408,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			Title:       "Toggle Sidebar",
 			Description: "Toggle between compact and normal layout",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ToggleCompactModeMsg{})
+				return uiutil.CmdHandler(ActionToggleCompactMode{})
 			},
 		})
 	}
@@ -416,7 +423,9 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 				Shortcut:    "ctrl+f",
 				Description: "Open file picker",
 				Handler: func(cmd uicmd.Command) tea.Cmd {
-					return uiutil.CmdHandler(OpenFilePickerMsg{})
+					return uiutil.CmdHandler(ActionOpenDialog{
+						// TODO: Pass file picker dialog id
+					})
 				},
 			})
 		}
@@ -431,7 +440,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			Shortcut:    "ctrl+o",
 			Description: "Open external editor to compose message",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(OpenExternalEditorMsg{})
+				return uiutil.CmdHandler(ActionExternalEditor{})
 			},
 		})
 	}
@@ -442,7 +451,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			Title:       "Toggle Yolo Mode",
 			Description: "Toggle yolo mode",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ToggleYoloModeMsg{})
+				return uiutil.CmdHandler(ActionToggleYoloMode{})
 			},
 		},
 		{
@@ -451,7 +460,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			Shortcut:    "ctrl+g",
 			Description: "Toggle help",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ToggleHelpMsg{})
+				return uiutil.CmdHandler(ActionToggleHelp{})
 			},
 		},
 		{

internal/ui/dialog/dialog.go 🔗

@@ -14,11 +14,19 @@ var CloseKey = key.NewBinding(
 	key.WithHelp("esc", "exit"),
 )
 
+// Action represents an action taken in a dialog after handling a message.
+type Action interface{}
+
 // Dialog is a component that can be displayed on top of the UI.
 type Dialog interface {
+	// ID returns the unique identifier of the dialog.
 	ID() string
-	Update(msg tea.Msg) tea.Msg
-	View() string
+	// HandleMsg processes a message and returns an action. An [Action] can be
+	// anything and the caller is responsible for handling it appropriately.
+	HandleMsg(msg tea.Msg) Action
+	// Draw draws the dialog onto the provided screen within the specified area
+	// and returns the desired cursor position on the screen.
+	Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
 }
 
 // Overlay manages multiple dialogs as an overlay.
@@ -113,33 +121,34 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg {
 		return nil
 	}
 
-	return dialog.Update(msg)
+	return dialog.HandleMsg(msg)
 }
 
-// CenterPosition calculates the centered position for the dialog.
-func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle {
-	dialog := d.Dialog(dialogID)
-	if dialog == nil {
-		return uv.Rectangle{}
+// DrawCenterCursor draws the given string view centered in the screen area and
+// adjusts the cursor position accordingly.
+func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
+	width, height := lipgloss.Size(view)
+	center := common.CenterRect(area, width, height)
+	if cur != nil {
+		cur.X += center.Min.X
+		cur.Y += center.Min.Y
 	}
-	return d.centerPositionView(area, dialog.View())
+
+	uv.NewStyledString(view).Draw(scr, center)
 }
 
-func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle {
-	viewWidth := lipgloss.Width(view)
-	viewHeight := lipgloss.Height(view)
-	return common.CenterRect(area, viewWidth, viewHeight)
+// DrawCenter draws the given string view centered in the screen area.
+func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
+	DrawCenterCursor(scr, area, view, nil)
 }
 
 // Draw renders the overlay and its dialogs.
-func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
+func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	var cur *tea.Cursor
 	for _, dialog := range d.dialogs {
-		view := dialog.View()
-		center := d.centerPositionView(area, view)
-		if area.Overlaps(center) {
-			uv.NewStyledString(view).Draw(scr, center)
-		}
+		cur = dialog.Draw(scr, area)
 	}
+	return cur
 }
 
 // removeDialog removes a dialog from the stack.

internal/ui/dialog/messages.go 🔗

@@ -1,44 +0,0 @@
-package dialog
-
-import (
-	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/session"
-)
-
-// CloseMsg is a message to close the current dialog.
-type CloseMsg struct{}
-
-// QuitMsg is a message to quit the application.
-type QuitMsg = tea.QuitMsg
-
-// OpenDialogMsg is a message to open a dialog.
-type OpenDialogMsg struct {
-	DialogID string
-}
-
-// SessionSelectedMsg is a message indicating a session has been selected.
-type SessionSelectedMsg struct {
-	Session session.Session
-}
-
-// ModelSelectedMsg is a message indicating a model has been selected.
-type ModelSelectedMsg struct {
-	Model     config.SelectedModel
-	ModelType config.SelectedModelType
-}
-
-// Messages for commands
-type (
-	NewSessionsMsg         struct{}
-	OpenFilePickerMsg      struct{}
-	ToggleHelpMsg          struct{}
-	ToggleCompactModeMsg   struct{}
-	ToggleThinkingMsg      struct{}
-	OpenReasoningDialogMsg struct{}
-	OpenExternalEditorMsg  struct{}
-	ToggleYoloModeMsg      struct{}
-	CompactMsg             struct {
-		SessionID string
-	}
-)

internal/ui/dialog/models.go 🔗

@@ -15,6 +15,8 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
 )
 
 // ModelType represents the type of model to select.
@@ -76,8 +78,6 @@ type Models struct {
 	modelType ModelType
 	providers []catwalk.Provider
 
-	width, height int
-
 	keyMap struct {
 		Tab      key.Binding
 		UpDown   key.Binding
@@ -147,33 +147,18 @@ func NewModels(com *common.Common) (*Models, error) {
 	return m, nil
 }
 
-// SetSize sets the size of the dialog.
-func (m *Models) SetSize(width, height int) {
-	t := m.com.Styles
-	m.width = width
-	m.height = height
-	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
-	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
-		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
-		t.Dialog.HelpView.GetVerticalFrameSize() +
-		t.Dialog.View.GetVerticalFrameSize()
-	m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
-	m.list.SetSize(innerWidth, height-heightOffset)
-	m.help.SetWidth(width)
-}
-
 // ID implements Dialog.
 func (m *Models) ID() string {
 	return ModelsID
 }
 
-// Update implements Dialog.
-func (m *Models) Update(msg tea.Msg) tea.Msg {
+// HandleMsg implements Dialog.
+func (m *Models) HandleMsg(msg tea.Msg) Action {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, m.keyMap.Close):
-			return CloseMsg{}
+			return ActionClose{}
 		case key.Matches(msg, m.keyMap.Previous):
 			m.list.Focus()
 			if m.list.IsSelectedFirst() {
@@ -203,7 +188,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg {
 				break
 			}
 
-			return ModelSelectedMsg{
+			return ActionSelectModel{
 				Model:     modelItem.SelectedModel(),
 				ModelType: modelItem.SelectedModelType(),
 			}
@@ -222,9 +207,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg {
 			value := m.input.Value()
 			m.list.SetFilter(value)
 			m.list.ScrollToSelected()
-			if cmd != nil {
-				return cmd()
-			}
+			return ActionCmd{cmd}
 		}
 	}
 	return nil
@@ -255,9 +238,22 @@ func (m *Models) modelTypeRadioView() string {
 		smallRadio, textStyle.Render(ModelTypeSmall.String()))
 }
 
-// View implements Dialog.
-func (m *Models) View() string {
+// Draw implements [Dialog].
+func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := m.com.Styles
+	width := max(0, min(60, area.Dx()))
+	height := max(0, min(30, area.Dy()))
+	// TODO: Why do we need this 2?
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		// TODO: Why do we need this 2?
+		t.Dialog.View.GetVerticalFrameSize() + 2
+	m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	m.list.SetSize(innerWidth, height-heightOffset)
+	m.help.SetWidth(innerWidth)
+
 	titleStyle := t.Dialog.Title
 	dialogStyle := t.Dialog.View
 
@@ -266,10 +262,15 @@ func (m *Models) View() string {
 	headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
 		dialogStyle.GetHorizontalFrameSize()
 
-	header := common.DialogTitle(t, "Switch Model", m.width-headerOffset) + radios
+	header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios
+
+	helpView := ansi.Truncate(m.help.View(m), innerWidth, "")
+	view := HeaderInputListHelpView(t, width, m.list.Height(), header,
+		m.input.View(), m.list.Render(), helpView)
 
-	return HeaderInputListHelpView(t, m.width, m.list.Height(), header,
-		m.input.View(), m.list.Render(), m.help.View(m))
+	cur := m.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
 }
 
 // ShortHelp returns the short help view.

internal/ui/dialog/quit.go 🔗

@@ -5,6 +5,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
 )
 
 // QuitID is the identifier for the quit dialog.
@@ -25,6 +26,8 @@ type Quit struct {
 	}
 }
 
+var _ Dialog = (*Quit)(nil)
+
 // NewQuit creates a new quit confirmation dialog.
 func NewQuit(com *common.Common) *Quit {
 	q := &Quit{
@@ -64,34 +67,34 @@ func (*Quit) ID() string {
 	return QuitID
 }
 
-// Update implements [Model].
-func (q *Quit) Update(msg tea.Msg) tea.Msg {
+// HandleMsg implements [Model].
+func (q *Quit) HandleMsg(msg tea.Msg) Action {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, q.keyMap.Quit):
-			return QuitMsg{}
+			return ActionQuit{}
 		case key.Matches(msg, q.keyMap.Close):
-			return CloseMsg{}
+			return ActionClose{}
 		case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
 			q.selectedNo = !q.selectedNo
 		case key.Matches(msg, q.keyMap.EnterSpace):
 			if !q.selectedNo {
-				return QuitMsg{}
+				return ActionQuit{}
 			}
-			return CloseMsg{}
+			return ActionClose{}
 		case key.Matches(msg, q.keyMap.Yes):
-			return QuitMsg{}
+			return ActionQuit{}
 		case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
-			return CloseMsg{}
+			return ActionClose{}
 		}
 	}
 
 	return nil
 }
 
-// View implements [Dialog].
-func (q *Quit) View() string {
+// Draw implements [Dialog].
+func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	const question = "Are you sure you want to quit?"
 	baseStyle := q.com.Styles.Base
 	buttonOpts := []common.ButtonOpts{
@@ -108,7 +111,9 @@ func (q *Quit) View() string {
 		),
 	)
 
-	return q.com.Styles.BorderFocus.Render(content)
+	view := q.com.Styles.BorderFocus.Render(content)
+	DrawCenter(scr, area, view)
+	return nil
 }
 
 // ShortHelp implements [help.KeyMap].

internal/ui/dialog/sessions.go 🔗

@@ -9,6 +9,8 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
 )
 
 // SessionsID is the identifier for the session selector dialog.
@@ -16,7 +18,6 @@ const SessionsID = "session"
 
 // Session is a session selector dialog.
 type Session struct {
-	width, height      int
 	com                *common.Common
 	help               help.Model
 	list               *list.FilterableList
@@ -56,6 +57,8 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
 	s.help = help
 	s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
 	s.list.Focus()
+	s.list.SetSelected(s.selectedSessionInx)
+	s.list.ScrollToSelected()
 
 	s.input = textinput.New()
 	s.input.SetVirtualCursor(false)
@@ -84,37 +87,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
 	return s, nil
 }
 
-// SetSize sets the size of the dialog.
-func (s *Session) SetSize(width, height int) {
-	t := s.com.Styles
-	s.width = width
-	s.height = height
-	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
-	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
-		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
-		t.Dialog.HelpView.GetVerticalFrameSize() +
-		t.Dialog.View.GetVerticalFrameSize()
-	s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
-	s.list.SetSize(innerWidth, height-heightOffset)
-	s.help.SetWidth(width)
-
-	// Now that we know the height we can select the selected session and scroll to it.
-	s.list.SetSelected(s.selectedSessionInx)
-	s.list.ScrollToSelected()
-}
-
 // ID implements Dialog.
 func (s *Session) ID() string {
 	return SessionsID
 }
 
-// Update implements Dialog.
-func (s *Session) Update(msg tea.Msg) tea.Msg {
+// HandleMsg implements Dialog.
+func (s *Session) HandleMsg(msg tea.Msg) Action {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, s.keyMap.Close):
-			return CloseMsg{}
+			return ActionClose{}
 		case key.Matches(msg, s.keyMap.Previous):
 			s.list.Focus()
 			if s.list.IsSelectedFirst() {
@@ -136,7 +120,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg {
 		case key.Matches(msg, s.keyMap.Select):
 			if item := s.list.SelectedItem(); item != nil {
 				sessionItem := item.(*SessionItem)
-				return SessionSelectedMsg{sessionItem.Session}
+				return ActionSelectSession{sessionItem.Session}
 			}
 		default:
 			var cmd tea.Cmd
@@ -145,9 +129,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg {
 			s.list.SetFilter(value)
 			s.list.ScrollToTop()
 			s.list.SetSelected(0)
-			if cmd != nil {
-				return cmd()
-			}
+			return ActionCmd{cmd}
 		}
 	}
 	return nil
@@ -158,16 +140,35 @@ func (s *Session) Cursor() *tea.Cursor {
 	return InputCursor(s.com.Styles, s.input.Cursor())
 }
 
-// View implements [Dialog].
-func (s *Session) View() string {
+// Draw implements [Dialog].
+func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := s.com.Styles
+	width := max(0, min(120, area.Dx()))
+	height := max(0, min(30, area.Dy()))
+	// TODO: Why do we need this 2?
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		// TODO: Why do we need this 2?
+		t.Dialog.View.GetVerticalFrameSize() + 2
+	s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	s.list.SetSize(innerWidth, height-heightOffset)
+	s.help.SetWidth(innerWidth)
+
 	titleStyle := s.com.Styles.Dialog.Title
-	dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
+	dialogStyle := s.com.Styles.Dialog.View.Width(width)
 	header := common.DialogTitle(s.com.Styles, "Switch Session",
-		max(0, s.width-dialogStyle.GetHorizontalFrameSize()-
+		max(0, width-dialogStyle.GetHorizontalFrameSize()-
 			titleStyle.GetHorizontalFrameSize()))
 
-	return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header,
-		s.input.View(), s.list.Render(), s.help.View(s))
+	helpView := ansi.Truncate(s.help.View(s), innerWidth, "")
+	view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header,
+		s.input.View(), s.list.Render(), helpView)
+
+	cur := s.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
 }
 
 // ShortHelp implements [help.KeyMap].

internal/ui/dialog/sessions_item.go 🔗

@@ -74,7 +74,7 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
 	}
 
 	var infoText string
-	var infoLen int
+	var infoWidth int
 	lineWidth := width
 	if len(info) > 0 {
 		infoText = fmt.Sprintf(" %s ", info)
@@ -84,12 +84,12 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
 			infoText = t.Subtle.Render(infoText)
 		}
 
-		infoLen = lipgloss.Width(infoText)
+		infoWidth = lipgloss.Width(infoText)
 	}
 
-	title = ansi.Truncate(title, max(0, lineWidth), "")
-	titleLen := lipgloss.Width(title)
-	gap := strings.Repeat(" ", max(0, lineWidth-titleLen-infoLen))
+	title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
+	titleWidth := lipgloss.Width(title)
+	gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
 	content := title
 	if matches := len(m.MatchedIndexes); matches > 0 {
 		var lastPos int

internal/ui/model/ui.go 🔗

@@ -404,6 +404,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.completionsOpen {
 			m.completions.SetFiles(msg.Files)
 		}
+	default:
+		if m.dialog.HasDialogs() {
+			if cmd := m.handleDialogMsg(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	}
 
 	// This logic gets triggered on any message type, but should it?
@@ -690,6 +696,93 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
 	return tea.Batch(cmds...)
 }
 
+func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
+	var cmds []tea.Cmd
+	action := m.dialog.Update(msg)
+	if action == nil {
+		return tea.Batch(cmds...)
+	}
+
+	switch msg := action.(type) {
+	// Generic dialog messages
+	case dialog.ActionClose:
+		m.dialog.CloseFrontDialog()
+		if m.focus == uiFocusEditor {
+			cmds = append(cmds, m.textarea.Focus())
+		}
+	case dialog.ActionCmd:
+		if msg.Cmd != nil {
+			cmds = append(cmds, msg.Cmd)
+		}
+
+	// Session dialog messages
+	case dialog.ActionSelectSession:
+		m.dialog.CloseDialog(dialog.SessionsID)
+		cmds = append(cmds, m.loadSession(msg.Session.ID))
+
+	// Open dialog message
+	case dialog.ActionOpenDialog:
+		m.dialog.CloseDialog(dialog.CommandsID)
+		if cmd := m.openDialog(msg.DialogID); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+
+	// Command dialog messages
+	case dialog.ActionToggleYoloMode:
+		yolo := !m.com.App.Permissions.SkipRequests()
+		m.com.App.Permissions.SetSkipRequests(yolo)
+		m.setEditorPrompt(yolo)
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionNewSession:
+		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+			break
+		}
+		m.newSession()
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionSummarize:
+		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			break
+		}
+		err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+		if err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+		}
+	case dialog.ActionToggleHelp:
+		m.status.ToggleHelp()
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionQuit:
+		cmds = append(cmds, tea.Quit)
+	case dialog.ActionSelectModel:
+		if m.com.App.AgentCoordinator.IsBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			break
+		}
+
+		// TODO: Validate model API and authentication here?
+
+		cfg := m.com.Config()
+		if cfg == nil {
+			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+			break
+		}
+
+		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+		}
+
+		// XXX: Should this be in a separate goroutine?
+		go m.com.App.UpdateAgentModel(context.TODO())
+
+		modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
+		cmds = append(cmds, uiutil.ReportInfo(modelMsg))
+		m.dialog.CloseDialog(dialog.ModelsID)
+	}
+
+	return tea.Batch(cmds...)
+}
+
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	var cmds []tea.Cmd
 
@@ -729,96 +822,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 
 	// Route all messages to dialog if one is open.
 	if m.dialog.HasDialogs() {
-		msg := m.dialog.Update(msg)
-		if msg == nil {
-			return tea.Batch(cmds...)
-		}
-
-		switch msg := msg.(type) {
-		// Generic dialog messages
-		case dialog.CloseMsg:
-			m.dialog.CloseFrontDialog()
-			if m.focus == uiFocusEditor {
-				cmds = append(cmds, m.textarea.Focus())
-			}
-
-		// Session dialog messages
-		case dialog.SessionSelectedMsg:
-			m.dialog.CloseDialog(dialog.SessionsID)
-			cmds = append(cmds, m.loadSession(msg.Session.ID))
-
-		// Open dialog message
-		case dialog.OpenDialogMsg:
-			switch msg.DialogID {
-			case dialog.SessionsID:
-				if cmd := m.openSessionsDialog(); cmd != nil {
-					cmds = append(cmds, cmd)
-				}
-			case dialog.ModelsID:
-				if cmd := m.openModelsDialog(); cmd != nil {
-					cmds = append(cmds, cmd)
-				}
-			default:
-				// Unknown dialog
-				break
-			}
-
-			m.dialog.CloseDialog(dialog.CommandsID)
-
-		// Command dialog messages
-		case dialog.ToggleYoloModeMsg:
-			yolo := !m.com.App.Permissions.SkipRequests()
-			m.com.App.Permissions.SetSkipRequests(yolo)
-			m.setEditorPrompt(yolo)
-			m.dialog.CloseDialog(dialog.CommandsID)
-		case dialog.NewSessionsMsg:
-			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
-				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
-				break
-			}
-			m.newSession()
-			m.dialog.CloseDialog(dialog.CommandsID)
-		case dialog.CompactMsg:
-			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
-				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
-				break
-			}
-			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
-			if err != nil {
-				cmds = append(cmds, uiutil.ReportError(err))
-			}
-		case dialog.ToggleHelpMsg:
-			m.status.ToggleHelp()
-			m.dialog.CloseDialog(dialog.CommandsID)
-		case dialog.QuitMsg:
-			cmds = append(cmds, tea.Quit)
-		case dialog.ModelSelectedMsg:
-			if m.com.App.AgentCoordinator.IsBusy() {
-				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
-				break
-			}
-
-			// TODO: Validate model API and authentication here?
-
-			cfg := m.com.Config()
-			if cfg == nil {
-				cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
-				break
-			}
-
-			if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
-				cmds = append(cmds, uiutil.ReportError(err))
-			}
-
-			// XXX: Should this be in a separate goroutine?
-			go m.com.App.UpdateAgentModel(context.TODO())
-
-			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
-			cmds = append(cmds, uiutil.ReportInfo(modelMsg))
-			m.dialog.CloseDialog(dialog.ModelsID)
-		}
-
-		return tea.Batch(cmds...)
+		return m.handleDialogMsg(msg)
 	}
 
 	switch m.state {
@@ -1035,7 +1039,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 }
 
 // Draw implements [uv.Drawable] and draws the UI model.
-func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
+func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	layout := m.generateLayout(area.Dx(), area.Dy())
 
 	if m.layout != layout {
@@ -1132,36 +1136,20 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 		})
 	}
 
-	// This needs to come last to overlay on top of everything
+	// This needs to come last to overlay on top of everything. We always pass
+	// the full screen bounds because the dialogs will position themselves
+	// accordingly.
 	if m.dialog.HasDialogs() {
-		m.dialog.Draw(scr, area)
+		return m.dialog.Draw(scr, scr.Bounds())
 	}
-}
 
-// Cursor returns the cursor position and properties for the UI model. It
-// returns nil if the cursor should not be shown.
-func (m *UI) Cursor() *tea.Cursor {
-	if m.layout.editor.Dy() <= 0 {
-		// Don't show cursor if editor is not visible
-		return nil
-	}
-	if m.dialog.HasDialogs() {
-		if front := m.dialog.DialogLast(); front != nil {
-			c, ok := front.(uiutil.Cursor)
-			if ok {
-				cur := c.Cursor()
-				if cur != nil {
-					pos := m.dialog.CenterPosition(m.layout.area, front.ID())
-					cur.X += pos.Min.X
-					cur.Y += pos.Min.Y
-					return cur
-				}
-			}
-		}
-		return nil
-	}
 	switch m.focus {
 	case uiFocusEditor:
+		if m.layout.editor.Dy() <= 0 {
+			// Don't show cursor if editor is not visible
+			return nil
+		}
+
 		if m.textarea.Focused() {
 			cur := m.textarea.Cursor()
 			cur.X++ // Adjust for app margins
@@ -1181,11 +1169,10 @@ func (m *UI) View() tea.View {
 	var v tea.View
 	v.AltScreen = true
 	v.BackgroundColor = m.com.Styles.Background
-	v.Cursor = m.Cursor()
 	v.MouseMode = tea.MouseModeCellMotion
 
 	canvas := uv.NewScreenBuffer(m.width, m.height)
-	m.Draw(canvas, canvas.Bounds())
+	v.Cursor = m.Draw(canvas, canvas.Bounds())
 
 	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
 	contentLines := strings.Split(content, "\n")
@@ -1813,6 +1800,33 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
 	return tea.Batch(cmds...)
 }
 
+// openDialog opens a dialog by its ID.
+func (m *UI) openDialog(id string) tea.Cmd {
+	var cmds []tea.Cmd
+	switch id {
+	case dialog.SessionsID:
+		if cmd := m.openSessionsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.ModelsID:
+		if cmd := m.openModelsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.CommandsID:
+		if cmd := m.openCommandsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.QuitID:
+		if cmd := m.openQuitDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	default:
+		// Unknown dialog
+		break
+	}
+	return tea.Batch(cmds...)
+}
+
 // openQuitDialog opens the quit confirmation dialog.
 func (m *UI) openQuitDialog() tea.Cmd {
 	if m.dialog.ContainsDialog(dialog.QuitID) {
@@ -1839,7 +1853,6 @@ func (m *UI) openModelsDialog() tea.Cmd {
 		return uiutil.ReportError(err)
 	}
 
-	modelsDialog.SetSize(min(60, m.width-8), 30)
 	m.dialog.OpenDialog(modelsDialog)
 
 	return nil
@@ -1863,8 +1876,6 @@ func (m *UI) openCommandsDialog() tea.Cmd {
 		return uiutil.ReportError(err)
 	}
 
-	// TODO: Get. Rid. Of. Magic numbers!
-	commands.SetSize(min(120, m.width-8), 30)
 	m.dialog.OpenDialog(commands)
 
 	return nil
@@ -1890,8 +1901,6 @@ func (m *UI) openSessionsDialog() tea.Cmd {
 		return uiutil.ReportError(err)
 	}
 
-	// TODO: Get. Rid. Of. Magic numbers!
-	dialog.SetSize(min(120, m.width-8), 30)
 	m.dialog.OpenDialog(dialog)
 
 	return nil
@@ -1915,6 +1924,10 @@ func (m *UI) newSession() {
 
 // handlePasteMsg handles a paste message.
 func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
+	if m.dialog.HasDialogs() {
+		return m.handleDialogMsg(msg)
+	}
+
 	if m.focus != uiFocusEditor {
 		return nil
 	}