Merge pull request #1941 from charmbracelet/small_ui_fixes

Ayman Bagabas created

Small fixes to the new UI

Change summary

internal/agent/coordinator.go   |  5 +++++
internal/ui/dialog/commands.go  | 11 ++++++++---
internal/ui/dialog/models.go    |  5 ++++-
internal/ui/dialog/sessions.go  |  9 ++++++++-
internal/ui/model/keys.go       |  4 ++--
internal/ui/model/lsp.go        |  9 ++++++++-
internal/ui/model/onboarding.go | 25 +++++++++++++++++++------
internal/ui/model/ui.go         | 30 ++++++++++++++++++++++++++----
8 files changed, 80 insertions(+), 18 deletions(-)

Detailed changes

internal/agent/coordinator.go 🔗

@@ -117,6 +117,11 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 		return nil, err
 	}
 
+	// refresh models before each run
+	if err := c.UpdateModels(ctx); err != nil {
+		return nil, fmt.Errorf("failed to update models: %w", err)
+	}
+
 	model := c.currentAgent.Model()
 	maxTokens := model.CatwalkCfg.DefaultMaxTokens
 	if model.ModelCfg.MaxTokens != 0 {

internal/ui/dialog/commands.go 🔗

@@ -28,7 +28,10 @@ type CommandType uint
 // String returns the string representation of the CommandType.
 func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
 
-const sidebarCompactModeBreakpoint = 120
+const (
+	sidebarCompactModeBreakpoint  = 120
+	defaultCommandsDialogMaxWidth = 70
+)
 
 const (
 	SystemCommands CommandType = iota
@@ -238,7 +241,7 @@ func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds boo
 // Draw implements [Dialog].
 func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := c.com.Styles
-	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()))
 	height := max(0, min(defaultDialogHeight, area.Dy()))
 	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
 		c.windowWidth = area.Dx()
@@ -254,7 +257,9 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		t.Dialog.View.GetVerticalFrameSize()
 
 	c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
-	c.list.SetSize(innerWidth, height-heightOffset)
+
+	listHeight := min(height-heightOffset, c.list.Len())
+	c.list.SetSize(innerWidth, listHeight)
 	c.help.SetWidth(innerWidth)
 
 	rc := NewRenderContext(t, width)

internal/ui/dialog/models.go 🔗

@@ -69,6 +69,8 @@ const (
 // ModelsID is the identifier for the model selection dialog.
 const ModelsID = "models"
 
+const defaultModelsDialogMaxWidth = 70
+
 // Models represents a model selection dialog.
 type Models struct {
 	com *common.Common
@@ -240,7 +242,7 @@ func (m *Models) modelTypeRadioView() string {
 // Draw implements [Dialog].
 func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := m.com.Styles
-	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	width := max(0, min(defaultModelsDialogMaxWidth, area.Dx()))
 	height := max(0, min(defaultDialogHeight, area.Dy()))
 	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
 	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
@@ -442,6 +444,7 @@ func (m *Models) setProviderItems() error {
 	// Set model groups in the list.
 	m.list.SetGroups(groups...)
 	m.list.SetSelectedItem(selectedItemID)
+	m.list.ScrollToSelected()
 
 	// Update placeholder based on model type
 	m.input.Placeholder = m.modelType.Placeholder()

internal/ui/dialog/sessions.go 🔗

@@ -57,7 +57,6 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
 	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)
@@ -153,6 +152,14 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	s.list.SetSize(innerWidth, height-heightOffset)
 	s.help.SetWidth(innerWidth)
 
+	// This makes it so we do not scroll the list if we don't have to
+	start, end := s.list.VisibleItemIndices()
+
+	// if selected index is outside visible range, scroll to it
+	if s.selectedSessionInx < start || s.selectedSessionInx > end {
+		s.list.ScrollToSelected()
+	}
+
 	rc := NewRenderContext(t, width)
 	rc.Title = "Switch Session"
 	inputView := t.Dialog.InputPrompt.Render(s.input.View())

internal/ui/model/keys.go 🔗

@@ -166,11 +166,11 @@ func DefaultKeyMap() KeyMap {
 	)
 
 	km.Chat.Down = key.NewBinding(
-		key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
+		key.WithKeys("down", "ctrl+j", "j"),
 		key.WithHelp("↓", "down"),
 	)
 	km.Chat.Up = key.NewBinding(
-		key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
+		key.WithKeys("up", "ctrl+k", "k"),
 		key.WithHelp("↑", "up"),
 	)
 	km.Chat.UpDown = key.NewBinding(

internal/ui/model/lsp.go 🔗

@@ -23,8 +23,14 @@ type LSPInfo struct {
 func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
 	var lsps []LSPInfo
 	t := m.com.Styles
+	lspConfigs := m.com.Config().LSP.Sorted()
+
+	for _, cfg := range lspConfigs {
+		state, ok := m.lspStates[cfg.Name]
+		if !ok {
+			continue
+		}
 
-	for _, state := range m.lspStates {
 		client, ok := m.com.App.LSPClients.Get(state.Name)
 		if !ok {
 			continue
@@ -39,6 +45,7 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
 
 		lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
 	}
+
 	title := t.Subtle.Render("LSPs")
 	if isSection {
 		title = common.Section(t, title, width)

internal/ui/model/onboarding.go 🔗

@@ -8,9 +8,12 @@ import (
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
 )
 
 // markProjectInitialized marks the current project as initialized in the config.
@@ -44,12 +47,22 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 
 // initializeProject starts project initialization and transitions to the landing view.
 func (m *UI) initializeProject() tea.Cmd {
-	// TODO: initialize the project
-	// for now we just go to the landing page
-	m.state = uiLanding
-	m.focus = uiFocusEditor
-	// TODO: actually send a message to the agent
-	return m.markProjectInitialized
+	// clear the session
+	m.newSession()
+	cfg := m.com.Config()
+	var cmds []tea.Cmd
+
+	initialize := func() tea.Msg {
+		initPrompt, err := agent.InitializePrompt(*cfg)
+		if err != nil {
+			return uiutil.InfoMsg{Type: uiutil.InfoTypeError, Msg: err.Error()}
+		}
+		return sendMessageMsg{Content: initPrompt}
+	}
+	// Mark the project as initialized
+	cmds = append(cmds, initialize, m.markProjectInitialized)
+
+	return tea.Sequence(cmds...)
 }
 
 // skipInitializeProject skips project initialization and transitions to the landing view.

internal/ui/model/ui.go 🔗

@@ -265,6 +265,8 @@ func New(com *common.Common) *UI {
 		completions: comp,
 		attachments: attachments,
 		todoSpinner: todoSpinner,
+		lspStates:   make(map[string]app.LSPClientInfo),
+		mcpStates:   make(map[string]mcp.ClientInfo),
 	}
 
 	status := NewStatus(com, ui)
@@ -1106,6 +1108,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			break
 		}
 		cmds = append(cmds, m.initializeProject())
+		m.dialog.CloseDialog(dialog.CommandsID)
 
 	case dialog.ActionSelectModel:
 		if m.isAgentBusy() {
@@ -1329,6 +1332,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				}
 				return true
 			}
+		case key.Matches(msg, m.keyMap.Suspend):
+			if m.isAgentBusy() {
+				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+				return true
+			}
+			cmds = append(cmds, tea.Suspend)
+			return true
 		}
 		return false
 	}
@@ -1430,10 +1440,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				}
 				m.newSession()
 			case key.Matches(msg, m.keyMap.Tab):
-				m.focus = uiFocusMain
-				m.textarea.Blur()
-				m.chat.Focus()
-				m.chat.SetSelected(m.chat.Len() - 1)
+				if m.state != uiLanding {
+					m.focus = uiFocusMain
+					m.textarea.Blur()
+					m.chat.Focus()
+					m.chat.SetSelected(m.chat.Len() - 1)
+				}
 			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
 				if m.isAgentBusy() {
 					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
@@ -1506,6 +1518,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				m.focus = uiFocusEditor
 				cmds = append(cmds, m.textarea.Focus())
 				m.chat.Blur()
+			case key.Matches(msg, m.keyMap.Chat.NewSession):
+				if !m.hasSession() {
+					break
+				}
+				if m.isAgentBusy() {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+					break
+				}
+				m.focus = uiFocusEditor
+				m.newSession()
 			case key.Matches(msg, m.keyMap.Chat.Expand):
 				m.chat.ToggleExpandedSelectedItem()
 			case key.Matches(msg, m.keyMap.Chat.Up):