refactor(ui): dialog: improve command and session dialogs

Ayman Bagabas created

Change summary

internal/ui/dialog/commands.go |  87 ++++++------------
internal/ui/dialog/common.go   |  56 +++++++++++
internal/ui/dialog/dialog.go   |  54 +++++++---
internal/ui/dialog/messages.go |  34 +++++++
internal/ui/dialog/quit.go     |  14 +-
internal/ui/dialog/sessions.go |  66 +++----------
internal/ui/model/ui.go        | 169 ++++++++++++++++++++++-------------
internal/ui/styles/styles.go   |   6 +
8 files changed, 291 insertions(+), 195 deletions(-)

Detailed changes

internal/ui/dialog/commands.go 🔗

@@ -18,6 +18,7 @@ import (
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/uicmd"
 	"github.com/charmbracelet/crush/internal/uiutil"
 )
@@ -32,24 +33,6 @@ type SendMsg struct {
 	Attachments []message.Attachment
 }
 
-// Messages for commands
-type (
-	SwitchSessionsMsg      struct{}
-	NewSessionsMsg         struct{}
-	SwitchModelMsg         struct{}
-	QuitMsg                struct{}
-	OpenFilePickerMsg      struct{}
-	ToggleHelpMsg          struct{}
-	ToggleCompactModeMsg   struct{}
-	ToggleThinkingMsg      struct{}
-	OpenReasoningDialogMsg struct{}
-	OpenExternalEditorMsg  struct{}
-	ToggleYoloModeMsg      struct{}
-	CompactMsg             struct {
-		SessionID string
-	}
-)
-
 // Commands represents a dialog that shows available commands.
 type Commands struct {
 	com    *common.Common
@@ -149,10 +132,12 @@ func (c *Commands) ID() string {
 }
 
 // Update implements Dialog.
-func (c *Commands) Update(msg tea.Msg) tea.Cmd {
+func (c *Commands) Update(msg tea.Msg) tea.Msg {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
+		case key.Matches(msg, c.keyMap.Close):
+			return CloseMsg{}
 		case key.Matches(msg, c.keyMap.Previous):
 			c.list.Focus()
 			c.list.SelectPrev()
@@ -175,8 +160,10 @@ func (c *Commands) Update(msg tea.Msg) tea.Cmd {
 		default:
 			var cmd tea.Cmd
 			c.input, cmd = c.input.Update(msg)
-			// Update the list filter
-			c.list.SetFilter(c.input.Value())
+			value := c.input.Value()
+			c.list.SetFilter(value)
+			c.list.ScrollToTop()
+			c.list.SetSelected(0)
 			return cmd
 		}
 	}
@@ -195,14 +182,17 @@ func (c *Commands) ReloadMCPPrompts() tea.Cmd {
 
 // Cursor returns the cursor position relative to the dialog.
 func (c *Commands) Cursor() *tea.Cursor {
-	return c.input.Cursor()
+	return InputCursor(c.com.Styles, c.input.Cursor())
 }
 
-// View implements [Dialog].
-func (c *Commands) View() string {
-	t := c.com.Styles
+// radioView generates the command type selector radio buttons.
+func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
+	if !hasUserCmds && !hasMCPPrompts {
+		return ""
+	}
+
 	selectedFn := func(t uicmd.CommandType) string {
-		if t == c.selected {
+		if t == selected {
 			return "◉ " + t.String()
 		}
 		return "○ " + t.String()
@@ -211,46 +201,27 @@ func (c *Commands) View() string {
 	parts := []string{
 		selectedFn(uicmd.SystemCommands),
 	}
-	if len(c.userCmds) > 0 {
+	if hasUserCmds {
 		parts = append(parts, selectedFn(uicmd.UserCommands))
 	}
-	if c.mcpPrompts.Len() > 0 {
+	if hasMCPPrompts {
 		parts = append(parts, selectedFn(uicmd.MCPPrompts))
 	}
 
 	radio := strings.Join(parts, " ")
-	radio = t.Dialog.Commands.CommandTypeSelector.Render(radio)
-	if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
-		radio = " " + radio
-	}
+	return t.Dialog.Commands.CommandTypeSelector.Render(radio)
+}
 
+// View implements [Dialog].
+func (c *Commands) View() string {
+	t := c.com.Styles
+	radio := radioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
 	titleStyle := t.Dialog.Title
-	helpStyle := t.Dialog.HelpView
 	dialogStyle := t.Dialog.View.Width(c.width)
-	inputStyle := t.Dialog.InputPrompt
-	helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize())
-
 	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
 	header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
-	title := titleStyle.Render(header)
-	help := helpStyle.Render(c.help.View(c))
-	listContent := c.list.Render()
-	if nlines := lipgloss.Height(listContent); nlines < c.list.Height() {
-		// pad the list content to avoid jumping when navigating
-		listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines))
-	}
-
-	content := strings.Join([]string{
-		title,
-		"",
-		inputStyle.Render(c.input.View()),
-		"",
-		c.list.Render(),
-		"",
-		help,
-	}, "\n")
-
-	return dialogStyle.Render(content)
+	return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
+		c.input.View(), c.list.Render(), c.help.View(c))
 }
 
 // ShortHelp implements [help.KeyMap].
@@ -316,7 +287,9 @@ func (c *Commands) setCommandType(commandType uicmd.CommandType) {
 	}
 
 	c.list.SetItems(commandItems...)
-	// Reset selection and filter
+	c.list.SetSelected(0)
+	c.list.SetFilter("")
+	c.list.ScrollToTop()
 	c.list.SetSelected(0)
 	c.input.SetValue("")
 }
@@ -485,7 +458,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 			Description: "Quit",
 			Shortcut:    "ctrl+c",
 			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(QuitMsg{})
+				return uiutil.CmdHandler(tea.QuitMsg{})
 			},
 		},
 	}...)

internal/ui/dialog/common.go 🔗

@@ -0,0 +1,56 @@
+package dialog
+
+import (
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// InputCursor adjusts the cursor position for an input field within a dialog.
+func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
+	if cur != nil {
+		titleStyle := t.Dialog.Title
+		dialogStyle := t.Dialog.View
+		inputStyle := t.Dialog.InputPrompt
+		// Adjust cursor position to account for dialog layout
+		cur.X += inputStyle.GetBorderLeftSize() +
+			inputStyle.GetMarginLeft() +
+			inputStyle.GetPaddingLeft() +
+			dialogStyle.GetBorderLeftSize() +
+			dialogStyle.GetPaddingLeft() +
+			dialogStyle.GetMarginLeft()
+		cur.Y += titleStyle.GetVerticalFrameSize() +
+			inputStyle.GetBorderTopSize() +
+			inputStyle.GetMarginTop() +
+			inputStyle.GetPaddingTop() +
+			inputStyle.GetBorderBottomSize() +
+			inputStyle.GetMarginBottom() +
+			inputStyle.GetPaddingBottom() +
+			dialogStyle.GetPaddingTop() +
+			dialogStyle.GetMarginTop() +
+			dialogStyle.GetBorderTopSize()
+	}
+	return cur
+}
+
+// HeaderInputListHelpView generates a view for dialogs with a header, input,
+// list, and help sections.
+func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
+	titleStyle := t.Dialog.Title
+	helpStyle := t.Dialog.HelpView
+	dialogStyle := t.Dialog.View.Width(width)
+	inputStyle := t.Dialog.InputPrompt
+	helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize())
+	listStyle := t.Dialog.List.Height(listHeight)
+	listContent := listStyle.Render(list)
+
+	content := strings.Join([]string{
+		titleStyle.Render(header),
+		inputStyle.Render(input),
+		listContent,
+		helpStyle.Render(help),
+	}, "\n")
+
+	return dialogStyle.Render(content)
+}

internal/ui/dialog/dialog.go 🔗

@@ -17,7 +17,7 @@ var CloseKey = key.NewBinding(
 // Dialog is a component that can be displayed on top of the UI.
 type Dialog interface {
 	ID() string
-	Update(msg tea.Msg) tea.Cmd
+	Update(msg tea.Msg) tea.Msg
 	View() string
 }
 
@@ -71,6 +71,14 @@ func (d *Overlay) RemoveDialog(dialogID string) {
 	}
 }
 
+// RemoveFrontDialog removes the front dialog from the stack.
+func (d *Overlay) RemoveFrontDialog() {
+	if len(d.dialogs) == 0 {
+		return
+	}
+	d.removeDialog(len(d.dialogs) - 1)
+}
+
 // Dialog returns the dialog with the specified ID, or nil if not found.
 func (d *Overlay) Dialog(dialogID string) Dialog {
 	for _, dialog := range d.dialogs {
@@ -81,6 +89,14 @@ func (d *Overlay) Dialog(dialogID string) Dialog {
 	return nil
 }
 
+// DialogLast returns the front dialog, or nil if there are no dialogs.
+func (d *Overlay) DialogLast() Dialog {
+	if len(d.dialogs) == 0 {
+		return nil
+	}
+	return d.dialogs[len(d.dialogs)-1]
+}
+
 // BringToFront brings the dialog with the specified ID to the front.
 func (d *Overlay) BringToFront(dialogID string) {
 	for i, dialog := range d.dialogs {
@@ -94,38 +110,40 @@ func (d *Overlay) BringToFront(dialogID string) {
 }
 
 // Update handles dialog updates.
-func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
+func (d *Overlay) Update(msg tea.Msg) tea.Msg {
 	if len(d.dialogs) == 0 {
-		return d, nil
+		return nil
 	}
 
 	idx := len(d.dialogs) - 1 // active dialog is the last one
 	dialog := d.dialogs[idx]
-	switch msg := msg.(type) {
-	case tea.KeyPressMsg:
-		if key.Matches(msg, CloseKey) {
-			// Close the current dialog
-			d.removeDialog(idx)
-			return d, nil
-		}
+	if dialog == nil {
+		return nil
 	}
 
-	if cmd := dialog.Update(msg); cmd != nil {
-		// Close the current dialog
-		d.removeDialog(idx)
-		return d, cmd
+	return dialog.Update(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{}
 	}
+	return d.centerPositionView(area, dialog.View())
+}
 
-	return d, nil
+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)
 }
 
 // Draw renders the overlay and its dialogs.
 func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
 	for _, dialog := range d.dialogs {
 		view := dialog.View()
-		viewWidth := lipgloss.Width(view)
-		viewHeight := lipgloss.Height(view)
-		center := common.CenterRect(area, viewWidth, viewHeight)
+		center := d.centerPositionView(area, view)
 		if area.Overlaps(center) {
 			uv.NewStyledString(view).Draw(scr, center)
 		}

internal/ui/dialog/messages.go 🔗

@@ -0,0 +1,34 @@
+package dialog
+
+import (
+	tea "charm.land/bubbletea/v2"
+	"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
+
+// SessionSelectedMsg is a message indicating a session has been selected.
+type SessionSelectedMsg struct {
+	Session session.Session
+}
+
+// Messages for commands
+type (
+	SwitchSessionsMsg      struct{}
+	NewSessionsMsg         struct{}
+	SwitchModelMsg         struct{}
+	OpenFilePickerMsg      struct{}
+	ToggleHelpMsg          struct{}
+	ToggleCompactModeMsg   struct{}
+	ToggleThinkingMsg      struct{}
+	OpenReasoningDialogMsg struct{}
+	OpenExternalEditorMsg  struct{}
+	ToggleYoloModeMsg      struct{}
+	CompactMsg             struct {
+		SessionID string
+	}
+)

internal/ui/dialog/quit.go 🔗

@@ -60,22 +60,24 @@ func (*Quit) ID() string {
 }
 
 // Update implements [Model].
-func (q *Quit) Update(msg tea.Msg) tea.Cmd {
+func (q *Quit) Update(msg tea.Msg) tea.Msg {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
+		case key.Matches(msg, q.keyMap.Close):
+			return CloseMsg{}
 		case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
 			q.selectedNo = !q.selectedNo
-			return nil
+			return CloseMsg{}
 		case key.Matches(msg, q.keyMap.EnterSpace):
 			if !q.selectedNo {
-				return tea.Quit
+				return QuitMsg{}
 			}
-			return nil
+			return CloseMsg{}
 		case key.Matches(msg, q.keyMap.Yes):
-			return tea.Quit
+			return QuitMsg{}
 		case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
-			return nil
+			return CloseMsg{}
 		}
 	}
 

internal/ui/dialog/sessions.go 🔗

@@ -1,13 +1,10 @@
 package dialog
 
 import (
-	"strings"
-
 	"charm.land/bubbles/v2/help"
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
@@ -34,11 +31,6 @@ type Session struct {
 
 var _ Dialog = (*Session)(nil)
 
-// SessionSelectedMsg is a message sent when a session is selected.
-type SessionSelectedMsg struct {
-	Session session.Session
-}
-
 // NewSessions creates a new Session dialog.
 func NewSessions(com *common.Common, sessions ...session.Session) *Session {
 	s := new(Session)
@@ -73,11 +65,6 @@ func NewSessions(com *common.Common, sessions ...session.Session) *Session {
 	return s
 }
 
-// Cursor returns the cursor position relative to the dialog.
-func (s *Session) Cursor() *tea.Cursor {
-	return s.input.Cursor()
-}
-
 // SetSize sets the size of the dialog.
 func (s *Session) SetSize(width, height int) {
 	s.width = width
@@ -94,10 +81,12 @@ func (s *Session) ID() string {
 }
 
 // Update implements Dialog.
-func (s *Session) Update(msg tea.Msg) tea.Cmd {
+func (s *Session) Update(msg tea.Msg) tea.Msg {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
+		case key.Matches(msg, s.keyMap.Close):
+			return CloseMsg{}
 		case key.Matches(msg, s.keyMap.Previous):
 			s.list.Focus()
 			s.list.SelectPrev()
@@ -109,48 +98,36 @@ func (s *Session) Update(msg tea.Msg) tea.Cmd {
 		case key.Matches(msg, s.keyMap.Select):
 			if item := s.list.SelectedItem(); item != nil {
 				sessionItem := item.(*SessionItem)
-				return SessionSelectCmd(sessionItem.Session)
+				return SessionSelectedMsg{sessionItem.Session}
 			}
 		default:
 			var cmd tea.Cmd
 			s.input, cmd = s.input.Update(msg)
-			s.list.SetFilter(s.input.Value())
+			value := s.input.Value()
+			s.list.SetFilter(value)
+			s.list.ScrollToTop()
+			s.list.SetSelected(0)
 			return cmd
 		}
 	}
 	return nil
 }
 
+// Cursor returns the cursor position relative to the dialog.
+func (s *Session) Cursor() *tea.Cursor {
+	return InputCursor(s.com.Styles, s.input.Cursor())
+}
+
 // View implements [Dialog].
 func (s *Session) View() string {
 	titleStyle := s.com.Styles.Dialog.Title
-	helpStyle := s.com.Styles.Dialog.HelpView
 	dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
-	inputStyle := s.com.Styles.Dialog.InputPrompt
-	helpStyle = helpStyle.Width(s.width - dialogStyle.GetHorizontalFrameSize())
-	listContent := s.list.Render()
-	if nlines := lipgloss.Height(listContent); nlines < s.list.Height() {
-		// pad the list content to avoid jumping when navigating
-		listContent += strings.Repeat("\n", max(0, s.list.Height()-nlines))
-	}
+	header := common.DialogTitle(s.com.Styles, "Switch Session",
+		max(0, s.width-dialogStyle.GetHorizontalFrameSize()-
+			titleStyle.GetHorizontalFrameSize()))
 
-	content := strings.Join([]string{
-		titleStyle.Render(
-			common.DialogTitle(
-				s.com.Styles,
-				"Switch Session",
-				max(0, s.width-
-					dialogStyle.GetHorizontalFrameSize()-
-					titleStyle.GetHorizontalFrameSize()))),
-		"",
-		inputStyle.Render(s.input.View()),
-		"",
-		listContent,
-		"",
-		helpStyle.Render(s.help.View(s)),
-	}, "\n")
-
-	return dialogStyle.Render(content)
+	return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header,
+		s.input.View(), s.list.Render(), s.help.View(s))
 }
 
 // ShortHelp implements [help.KeyMap].
@@ -181,10 +158,3 @@ func (s *Session) FullHelp() [][]key.Binding {
 	}
 	return m
 }
-
-// SessionSelectCmd creates a command that sends a SessionSelectMsg.
-func SessionSelectCmd(s session.Session) tea.Cmd {
-	return func() tea.Msg {
-		return SessionSelectedMsg{Session: s}
-	}
-}

internal/ui/model/ui.go 🔗

@@ -20,11 +20,11 @@ import (
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/uiutil"
 	"github.com/charmbracelet/crush/internal/version"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/ultraviolet/screen"
@@ -194,12 +194,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// TODO: Get. Rid. Of. Magic numbers!
 		sessions.SetSize(min(120, m.width-8), 30)
 		m.dialog.AddDialog(sessions)
-	case dialog.SessionSelectedMsg:
-		m.dialog.RemoveDialog(dialog.SessionsID)
-		cmds = append(cmds,
-			m.loadSession(msg.Session.ID),
-			m.loadSessionFiles(msg.Session.ID),
-		)
 	case sessionLoadedMsg:
 		m.state = uiChat
 		m.session = &msg.sess
@@ -330,15 +324,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case tea.KeyPressMsg:
 		cmds = append(cmds, m.handleKeyPressMsg(msg)...)
-
-	// Command dialog messages
-	// TODO: Properly structure and handle these messages
-	case dialog.ToggleYoloModeMsg:
-		m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests())
-		m.dialog.RemoveDialog(dialog.CommandsID)
-	case dialog.SwitchSessionsMsg:
-		cmds = append(cmds, m.loadSessionsCmd)
-		m.dialog.RemoveDialog(dialog.CommandsID)
 	}
 
 	// This logic gets triggered on any message type, but should it?
@@ -384,7 +369,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 			return true
 		}
 		switch {
-		case key.Matches(msg, m.keyMap.Tab):
 		case key.Matches(msg, m.keyMap.Help):
 			m.help.ShowAll = !m.help.ShowAll
 			m.updateLayoutAndSize()
@@ -400,7 +384,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 				}
 				commands, err := dialog.NewCommands(m.com, sessionID)
 				if err != nil {
-					cmds = append(cmds, util.ReportError(err))
+					cmds = append(cmds, uiutil.ReportError(err))
 				} else {
 					// TODO: Get. Rid. Of. Magic numbers!
 					commands.SetSize(min(120, m.width-8), 30)
@@ -427,64 +411,101 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 			return cmds
 		}
 
-		updatedDialog, cmd := m.dialog.Update(msg)
-		m.dialog = updatedDialog
-		if cmd != nil {
-			cmds = append(cmds, cmd)
+		msg := m.dialog.Update(msg)
+		if msg == nil {
+			return cmds
 		}
+
+		switch msg := msg.(type) {
+		// Generic dialog messages
+		case dialog.CloseMsg:
+			m.dialog.RemoveFrontDialog()
+		// Session dialog messages
+		case dialog.SessionSelectedMsg:
+			m.dialog.RemoveDialog(dialog.SessionsID)
+			cmds = append(cmds,
+				m.loadSession(msg.Session.ID),
+				m.loadSessionFiles(msg.Session.ID),
+			)
+		// Command dialog messages
+		case dialog.ToggleYoloModeMsg:
+			m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests())
+			m.dialog.RemoveDialog(dialog.CommandsID)
+		case dialog.SwitchSessionsMsg:
+			cmds = append(cmds, m.loadSessionsCmd)
+			m.dialog.RemoveDialog(dialog.CommandsID)
+		case dialog.CompactMsg:
+			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+			if err != nil {
+				cmds = append(cmds, uiutil.ReportError(err))
+			}
+		case dialog.ToggleHelpMsg:
+			m.help.ShowAll = !m.help.ShowAll
+		case dialog.QuitMsg:
+			cmds = append(cmds, tea.Quit)
+		}
+
 		return cmds
 	}
 
 	switch m.state {
 	case uiChat:
-		switch {
-		case key.Matches(msg, m.keyMap.Tab):
-			if m.focus == uiFocusMain {
-				m.focus = uiFocusEditor
-				cmds = append(cmds, m.textarea.Focus())
-				m.chat.Blur()
-			} else {
+		switch m.focus {
+		case uiFocusEditor:
+			switch {
+			case key.Matches(msg, m.keyMap.Tab):
 				m.focus = uiFocusMain
 				m.textarea.Blur()
 				m.chat.Focus()
 				m.chat.SetSelected(m.chat.Len() - 1)
+			default:
+				handleGlobalKeys(msg)
 			}
-		case key.Matches(msg, m.keyMap.Chat.Up):
-			m.chat.ScrollBy(-1)
-			if !m.chat.SelectedItemInView() {
+		case uiFocusMain:
+			switch {
+			case key.Matches(msg, m.keyMap.Tab):
+				m.focus = uiFocusEditor
+				cmds = append(cmds, m.textarea.Focus())
+				m.chat.Blur()
+			case key.Matches(msg, m.keyMap.Chat.Up):
+				m.chat.ScrollBy(-1)
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectPrev()
+					m.chat.ScrollToSelected()
+				}
+			case key.Matches(msg, m.keyMap.Chat.Down):
+				m.chat.ScrollBy(1)
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectNext()
+					m.chat.ScrollToSelected()
+				}
+			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
 				m.chat.SelectPrev()
 				m.chat.ScrollToSelected()
-			}
-		case key.Matches(msg, m.keyMap.Chat.Down):
-			m.chat.ScrollBy(1)
-			if !m.chat.SelectedItemInView() {
+			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
 				m.chat.SelectNext()
 				m.chat.ScrollToSelected()
+			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
+				m.chat.ScrollBy(-m.chat.Height() / 2)
+				m.chat.SelectFirstInView()
+			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
+				m.chat.ScrollBy(m.chat.Height() / 2)
+				m.chat.SelectLastInView()
+			case key.Matches(msg, m.keyMap.Chat.PageUp):
+				m.chat.ScrollBy(-m.chat.Height())
+				m.chat.SelectFirstInView()
+			case key.Matches(msg, m.keyMap.Chat.PageDown):
+				m.chat.ScrollBy(m.chat.Height())
+				m.chat.SelectLastInView()
+			case key.Matches(msg, m.keyMap.Chat.Home):
+				m.chat.ScrollToTop()
+				m.chat.SelectFirst()
+			case key.Matches(msg, m.keyMap.Chat.End):
+				m.chat.ScrollToBottom()
+				m.chat.SelectLast()
+			default:
+				handleGlobalKeys(msg)
 			}
-		case key.Matches(msg, m.keyMap.Chat.UpOneItem):
-			m.chat.SelectPrev()
-			m.chat.ScrollToSelected()
-		case key.Matches(msg, m.keyMap.Chat.DownOneItem):
-			m.chat.SelectNext()
-			m.chat.ScrollToSelected()
-		case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
-			m.chat.ScrollBy(-m.chat.Height() / 2)
-			m.chat.SelectFirstInView()
-		case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
-			m.chat.ScrollBy(m.chat.Height() / 2)
-			m.chat.SelectLastInView()
-		case key.Matches(msg, m.keyMap.Chat.PageUp):
-			m.chat.ScrollBy(-m.chat.Height())
-			m.chat.SelectFirstInView()
-		case key.Matches(msg, m.keyMap.Chat.PageDown):
-			m.chat.ScrollBy(m.chat.Height())
-			m.chat.SelectLastInView()
-		case key.Matches(msg, m.keyMap.Chat.Home):
-			m.chat.ScrollToTop()
-			m.chat.SelectFirst()
-		case key.Matches(msg, m.keyMap.Chat.End):
-			m.chat.ScrollToBottom()
-			m.chat.SelectLast()
 		default:
 			handleGlobalKeys(msg)
 		}
@@ -588,11 +609,29 @@ func (m *UI) Cursor() *tea.Cursor {
 		// Don't show cursor if editor is not visible
 		return nil
 	}
-	if m.focus == uiFocusEditor && m.textarea.Focused() {
-		cur := m.textarea.Cursor()
-		cur.X++ // Adjust for app margins
-		cur.Y += m.layout.editor.Min.Y
-		return cur
+	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.textarea.Focused() {
+			cur := m.textarea.Cursor()
+			cur.X++ // Adjust for app margins
+			cur.Y += m.layout.editor.Min.Y
+			return cur
+		}
 	}
 	return nil
 }

internal/ui/styles/styles.go 🔗

@@ -277,6 +277,8 @@ type Styles struct {
 		SelectedItem lipgloss.Style
 		InputPrompt  lipgloss.Style
 
+		List lipgloss.Style
+
 		Commands struct {
 			CommandTypeSelector lipgloss.Style
 		}
@@ -909,7 +911,9 @@ func DefaultStyles() Styles {
 	s.Dialog.Help.FullSeparator = base.Foreground(border)
 	s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
 	s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
-	s.Dialog.InputPrompt = base.Padding(0, 1)
+	s.Dialog.InputPrompt = base.Margin(1, 1)
+
+	s.Dialog.List = base.Margin(0, 0, 1, 0)
 
 	s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted)