feat: implement onboarding flow on the new ui codebase

Andrey Nering created

Change summary

internal/app/app.go                 |  5 +-
internal/ui/common/common.go        | 10 +++++
internal/ui/dialog/api_key_input.go | 53 +++++++++++++++++++-------
internal/ui/dialog/common.go        | 11 ++++-
internal/ui/dialog/dialog.go        | 18 ++++++++
internal/ui/dialog/models.go        | 58 ++++++++++++++++++++++++-----
internal/ui/dialog/oauth.go         | 29 ++++++++++++--
internal/ui/dialog/oauth_copilot.go | 10 ++++-
internal/ui/dialog/oauth_hyper.go   | 10 ++++-
internal/ui/model/sidebar.go        |  5 +-
internal/ui/model/status.go         | 20 +++++++---
internal/ui/model/ui.go             | 62 +++++++++++++++++++++++++------
12 files changed, 233 insertions(+), 58 deletions(-)

Detailed changes

internal/app/app.go 🔗

@@ -351,15 +351,16 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
 
 	case largeModel != "":
 		// No small model specified, but large model was - use provider's default.
-		smallCfg := app.getDefaultSmallModel(largeProviderID)
+		smallCfg := app.GetDefaultSmallModel(largeProviderID)
 		app.config.Models[config.SelectedModelTypeSmall] = smallCfg
 	}
 
 	return app.AgentCoordinator.UpdateModels(ctx)
 }
 
+// GetDefaultSmallModel returns the default small model for the given
 // provider. Falls back to the large model if no default is found.
-func (app *App) getDefaultSmallModel(providerID string) config.SelectedModel {
+func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
 	cfg := app.config
 	largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
 

internal/ui/common/common.go 🔗

@@ -52,6 +52,16 @@ func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
 	return image.Rect(minX, minY, maxX, maxY)
 }
 
+// BottomLeftRect returns a new [Rectangle] positioned at the bottom-left within the given area with the
+// specified width and height.
+func BottomLeftRect(area uv.Rectangle, width, height int) uv.Rectangle {
+	minX := area.Min.X
+	maxX := minX + width
+	maxY := area.Max.Y
+	minY := maxY - height
+	return image.Rect(minX, minY, maxX, maxY)
+}
+
 // IsFileTooBig checks if the file at the given path exceeds the specified size
 // limit.
 func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {

internal/ui/dialog/api_key_input.go 🔗

@@ -33,7 +33,8 @@ const APIKeyInputID = "api_key_input"
 
 // APIKeyInput represents a model selection dialog.
 type APIKeyInput struct {
-	com *common.Common
+	com          *common.Common
+	isOnboarding bool
 
 	provider  catwalk.Provider
 	model     config.SelectedModel
@@ -54,11 +55,18 @@ type APIKeyInput struct {
 var _ Dialog = (*APIKeyInput)(nil)
 
 // NewAPIKeyInput creates a new Models dialog.
-func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) {
+func NewAPIKeyInput(
+	com *common.Common,
+	isOnboarding bool,
+	provider catwalk.Provider,
+	model config.SelectedModel,
+	modelType config.SelectedModelType,
+) (*APIKeyInput, tea.Cmd) {
 	t := com.Styles
 
 	m := APIKeyInput{}
 	m.com = com
+	m.isOnboarding = isOnboarding
 	m.provider = provider
 	m.model = model
 	m.modelType = modelType
@@ -170,28 +178,45 @@ func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		helpStyle.Render(m.help.View(m)),
 	}, "\n")
 
-	view := dialogStyle.Render(content)
-
 	cur := m.Cursor()
-	DrawCenterCursor(scr, area, view, cur)
+
+	if m.isOnboarding {
+		view := content
+		DrawOnboardingCursor(scr, area, view, cur)
+
+		// FIXME(@andreynering): Figure it out how to properly fix this
+		if cur != nil {
+			cur.Y -= 1
+			cur.X -= 1
+		}
+	} else {
+		view := dialogStyle.Render(content)
+		DrawCenterCursor(scr, area, view, cur)
+	}
 	return cur
 }
 
 func (m *APIKeyInput) headerView() string {
-	t := m.com.Styles
-	titleStyle := t.Dialog.Title
-	dialogStyle := t.Dialog.View.Width(m.width)
-
+	var (
+		t           = m.com.Styles
+		titleStyle  = t.Dialog.Title
+		textStyle   = t.Dialog.PrimaryText
+		dialogStyle = t.Dialog.View.Width(m.width)
+	)
+	if m.isOnboarding {
+		return textStyle.Render(m.dialogTitle())
+	}
 	headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
 	return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset)
 }
 
 func (m *APIKeyInput) dialogTitle() string {
-	t := m.com.Styles
-	textStyle := t.Dialog.TitleText
-	errorStyle := t.Dialog.TitleError
-	accentStyle := t.Dialog.TitleAccent
-
+	var (
+		t           = m.com.Styles
+		textStyle   = t.Dialog.TitleText
+		errorStyle  = t.Dialog.TitleError
+		accentStyle = t.Dialog.TitleAccent
+	)
 	switch m.state {
 	case APIKeyInputStateInitial:
 		return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".")

internal/ui/dialog/common.go 🔗

@@ -59,6 +59,10 @@ type RenderContext struct {
 	// Help is the help view content. This will be appended to the content parts
 	// slice using the default dialog help style.
 	Help string
+	// IsOnboarding indicates whether to render the dialog as part of the
+	// onboarding flow. This means that the content will be rendered at the
+	// bottom left of the screen.
+	IsOnboarding bool
 }
 
 // NewRenderContext creates a new RenderContext with the provided styles and width.
@@ -82,7 +86,8 @@ func (rc *RenderContext) Render() string {
 	titleStyle := rc.Styles.Dialog.Title
 	dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
 
-	parts := []string{}
+	var parts []string
+
 	if len(rc.Title) > 0 {
 		var titleInfoWidth int
 		if len(rc.TitleInfo) > 0 {
@@ -125,6 +130,8 @@ func (rc *RenderContext) Render() string {
 	}
 
 	content := strings.Join(parts, "\n")
-
+	if rc.IsOnboarding {
+		return content
+	}
 	return dialogStyle.Render(content)
 }

internal/ui/dialog/dialog.go 🔗

@@ -170,7 +170,6 @@ func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cu
 		cur.X += center.Min.X
 		cur.Y += center.Min.Y
 	}
-
 	uv.NewStyledString(view).Draw(scr, center)
 }
 
@@ -179,6 +178,23 @@ func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
 	DrawCenterCursor(scr, area, view, nil)
 }
 
+// DrawOnboarding draws the given string view centered in the screen area.
+func DrawOnboarding(scr uv.Screen, area uv.Rectangle, view string) {
+	DrawOnboardingCursor(scr, area, view, nil)
+}
+
+// DrawOnboardingCursor draws the given string view positioned at the bottom
+// left area of the screen.
+func DrawOnboardingCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
+	width, height := lipgloss.Size(view)
+	bottomLeft := common.BottomLeftRect(area, width, height)
+	if cur != nil {
+		cur.X += bottomLeft.Min.X
+		cur.Y += bottomLeft.Min.Y
+	}
+	uv.NewStyledString(view).Draw(scr, bottomLeft)
+}
+
 // Draw renders the overlay and its dialogs.
 func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	var cur *tea.Cursor

internal/ui/dialog/models.go 🔗

@@ -62,8 +62,9 @@ func (mt ModelType) Placeholder() string {
 }
 
 const (
-	largeModelInputPlaceholder = "Choose a model for large, complex tasks"
-	smallModelInputPlaceholder = "Choose a model for small, simple tasks"
+	onboardingModelInputPlaceholder = "Find your fave"
+	largeModelInputPlaceholder      = "Choose a model for large, complex tasks"
+	smallModelInputPlaceholder      = "Choose a model for small, simple tasks"
 )
 
 // ModelsID is the identifier for the model selection dialog.
@@ -73,7 +74,8 @@ const defaultModelsDialogMaxWidth = 70
 
 // Models represents a model selection dialog.
 type Models struct {
-	com *common.Common
+	com          *common.Common
+	isOnboarding bool
 
 	modelType ModelType
 	providers []catwalk.Provider
@@ -94,10 +96,12 @@ type Models struct {
 var _ Dialog = (*Models)(nil)
 
 // NewModels creates a new Models dialog.
-func NewModels(com *common.Common) (*Models, error) {
+func NewModels(com *common.Common, isOnboarding bool) (*Models, error) {
 	t := com.Styles
 	m := &Models{}
 	m.com = com
+	m.isOnboarding = isOnboarding
+
 	help := help.New()
 	help.Styles = t.DialogHelpStyles()
 
@@ -108,7 +112,7 @@ func NewModels(com *common.Common) (*Models, error) {
 
 	m.input = textinput.New()
 	m.input.SetVirtualCursor(false)
-	m.input.Placeholder = largeModelInputPlaceholder
+	m.input.Placeholder = onboardingModelInputPlaceholder
 	m.input.SetStyles(com.Styles.TextInput)
 	m.input.Focus()
 
@@ -194,6 +198,9 @@ func (m *Models) HandleMsg(msg tea.Msg) Action {
 				ModelType: modelItem.SelectedModelType(),
 			}
 		case key.Matches(msg, m.keyMap.Tab):
+			if m.isOnboarding {
+				break
+			}
 			if m.modelType == ModelTypeLarge {
 				m.modelType = ModelTypeSmall
 			} else {
@@ -251,6 +258,7 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
 		t.Dialog.HelpView.GetVerticalFrameSize() +
 		t.Dialog.View.GetVerticalFrameSize()
+
 	m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
 	m.list.SetSize(innerWidth, height-heightOffset)
 	m.help.SetWidth(innerWidth)
@@ -258,21 +266,49 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	rc := NewRenderContext(t, width)
 	rc.Title = "Switch Model"
 	rc.TitleInfo = m.modelTypeRadioView()
+
+	if m.isOnboarding {
+		titleText := t.Dialog.PrimaryText.Render("To start, let's choose a provider and model.")
+		rc.AddPart(titleText)
+	}
+
 	inputView := t.Dialog.InputPrompt.Render(m.input.View())
 	rc.AddPart(inputView)
+
 	listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
 	rc.AddPart(listView)
-	rc.Help = m.help.View(m)
 
-	view := rc.Render()
+	rc.Help = m.help.View(m)
 
 	cur := m.Cursor()
-	DrawCenterCursor(scr, area, view, cur)
+
+	if m.isOnboarding {
+		rc.Title = ""
+		rc.TitleInfo = ""
+		rc.IsOnboarding = true
+		view := rc.Render()
+		DrawOnboardingCursor(scr, area, view, cur)
+
+		// FIXME(@andreynering): Figure it out how to properly fix this
+		if cur != nil {
+			cur.Y -= 1
+			cur.X -= 1
+		}
+	} else {
+		view := rc.Render()
+		DrawCenterCursor(scr, area, view, cur)
+	}
 	return cur
 }
 
 // ShortHelp returns the short help view.
 func (m *Models) ShortHelp() []key.Binding {
+	if m.isOnboarding {
+		return []key.Binding{
+			m.keyMap.UpDown,
+			m.keyMap.Select,
+		}
+	}
 	return []key.Binding{
 		m.keyMap.UpDown,
 		m.keyMap.Tab,
@@ -459,10 +495,12 @@ func (m *Models) setProviderItems() error {
 	// Set model groups in the list.
 	m.list.SetGroups(groups...)
 	m.list.SetSelectedItem(selectedItemID)
-	m.list.ScrollToSelected()
+	m.list.ScrollToTop()
 
 	// Update placeholder based on model type
-	m.input.Placeholder = m.modelType.Placeholder()
+	if !m.isOnboarding {
+		m.input.Placeholder = m.modelType.Placeholder()
+	}
 
 	return nil
 }

internal/ui/dialog/oauth.go 🔗

@@ -41,7 +41,8 @@ const OAuthID = "oauth"
 
 // OAuth handles the OAuth flow authentication.
 type OAuth struct {
-	com *common.Common
+	com          *common.Common
+	isOnboarding bool
 
 	provider      catwalk.Provider
 	model         config.SelectedModel
@@ -71,11 +72,19 @@ type OAuth struct {
 var _ Dialog = (*OAuth)(nil)
 
 // newOAuth creates a new device flow component.
-func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) {
+func newOAuth(
+	com *common.Common,
+	isOnboarding bool,
+	provider catwalk.Provider,
+	model config.SelectedModel,
+	modelType config.SelectedModelType,
+	oAuthProvider OAuthProvider,
+) (*OAuth, tea.Cmd) {
 	t := com.Styles
 
 	m := OAuth{}
 	m.com = com
+	m.isOnboarding = isOnboarding
 	m.provider = provider
 	m.model = model
 	m.modelType = modelType
@@ -175,9 +184,14 @@ func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	var (
 		t           = m.com.Styles
 		dialogStyle = t.Dialog.View.Width(m.width)
-		view        = dialogStyle.Render(m.dialogContent())
 	)
-	DrawCenterCursor(scr, area, view, nil)
+	if m.isOnboarding {
+		view := m.dialogContent()
+		DrawOnboarding(scr, area, view)
+	} else {
+		view := dialogStyle.Render(m.dialogContent())
+		DrawCenter(scr, area, view)
+	}
 	return nil
 }
 
@@ -205,10 +219,15 @@ func (m *OAuth) headerContent() string {
 	var (
 		t            = m.com.Styles
 		titleStyle   = t.Dialog.Title
+		textStyle    = t.Dialog.PrimaryText
 		dialogStyle  = t.Dialog.View.Width(m.width)
 		headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
+		dialogTitle  = fmt.Sprintf("Authenticate with %s", m.oAuthProvider.name())
 	)
-	return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
+	if m.isOnboarding {
+		return textStyle.Render(dialogTitle)
+	}
+	return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset)
 }
 
 func (m *OAuth) innerDialogContent() string {

internal/ui/dialog/oauth_copilot.go 🔗

@@ -12,8 +12,14 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 )
 
-func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
-	return newOAuth(com, provider, model, modelType, &OAuthCopilot{})
+func NewOAuthCopilot(
+	com *common.Common,
+	isOnboarding bool,
+	provider catwalk.Provider,
+	model config.SelectedModel,
+	modelType config.SelectedModelType,
+) (*OAuth, tea.Cmd) {
+	return newOAuth(com, isOnboarding, provider, model, modelType, &OAuthCopilot{})
 }
 
 type OAuthCopilot struct {

internal/ui/dialog/oauth_hyper.go 🔗

@@ -12,8 +12,14 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 )
 
-func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
-	return newOAuth(com, provider, model, modelType, &OAuthHyper{})
+func NewOAuthHyper(
+	com *common.Common,
+	isOnboarding bool,
+	provider catwalk.Provider,
+	model config.SelectedModel,
+	modelType config.SelectedModelType,
+) (*OAuth, tea.Cmd) {
+	return newOAuth(com, isOnboarding, provider, model, modelType, &OAuthHyper{})
 }
 
 type OAuthHyper struct {

internal/ui/model/sidebar.go 🔗

@@ -45,14 +45,15 @@ func (m *UI) modelInfo(width int) string {
 	}
 
 	var modelContext *common.ModelContextInfo
-	if m.session != nil {
+	if model != nil && m.session != nil {
 		modelContext = &common.ModelContextInfo{
 			ContextUsed:  m.session.CompletionTokens + m.session.PromptTokens,
 			Cost:         m.session.Cost,
 			ModelContext: model.CatwalkCfg.ContextWindow,
 		}
+		return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
 	}
-	return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
+	return ""
 }
 
 // getDynamicHeightLimits will give us the num of items to show in each section based on the hight

internal/ui/model/status.go 🔗

@@ -17,10 +17,11 @@ const DefaultStatusTTL = 5 * time.Second
 
 // Status is the status bar and help model.
 type Status struct {
-	com    *common.Common
-	help   help.Model
-	helpKm help.KeyMap
-	msg    uiutil.InfoMsg
+	com      *common.Common
+	hideHelp bool
+	help     help.Model
+	helpKm   help.KeyMap
+	msg      uiutil.InfoMsg
 }
 
 // NewStatus creates a new status bar and help model.
@@ -58,10 +59,17 @@ func (s *Status) ToggleHelp() {
 	s.help.ShowAll = !s.help.ShowAll
 }
 
+// SetHideHelp sets whether the app is on the onboarding flow.
+func (s *Status) SetHideHelp(hideHelp bool) {
+	s.hideHelp = hideHelp
+}
+
 // Draw draws the status bar onto the screen.
 func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
-	helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm))
-	uv.NewStyledString(helpView).Draw(scr, area)
+	if !s.hideHelp {
+		helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm))
+		uv.NewStyledString(helpView).Draw(scr, area)
+	}
 
 	// Render notifications
 	if s.msg.IsEmpty() {

internal/ui/model/ui.go 🔗

@@ -299,6 +299,11 @@ func (m *UI) Init() tea.Cmd {
 	if m.QueryCapabilities {
 		cmds = append(cmds, tea.RequestTerminalVersion)
 	}
+	if m.state == uiOnboarding {
+		if cmd := m.openModelsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
 	// load the user commands async
 	cmds = append(cmds, m.loadCustomCommands())
 	return tea.Batch(cmds...)
@@ -1028,10 +1033,23 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		return tea.Batch(cmds...)
 	}
 
+	isOnboarding := m.state == uiOnboarding
+
 	switch msg := action.(type) {
 	// Generic dialog messages
 	case dialog.ActionClose:
+		if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
+			break
+		}
+
 		m.dialog.CloseFrontDialog()
+
+		if isOnboarding {
+			if cmd := m.openModelsDialog(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+
 		if m.focus == uiFocusEditor {
 			cmds = append(cmds, m.textarea.Focus())
 		}
@@ -1164,10 +1182,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
 			cmds = append(cmds, uiutil.ReportError(err))
+		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
+			// Ensure small model is set is unset.
+			smallModel := m.com.App.GetDefaultSmallModel(providerID)
+			if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
+				cmds = append(cmds, uiutil.ReportError(err))
+			}
 		}
 
 		cmds = append(cmds, func() tea.Msg {
-			m.com.App.UpdateAgentModel(context.TODO())
+			if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
+				return uiutil.ReportError(err)
+			}
 
 			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
 
@@ -1177,6 +1203,16 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.APIKeyInputID)
 		m.dialog.CloseDialog(dialog.OAuthID)
 		m.dialog.CloseDialog(dialog.ModelsID)
+
+		if isOnboarding {
+			m.state = uiLanding
+			m.focus = uiFocusEditor
+
+			m.com.Config().SetupAgents()
+			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
+				cmds = append(cmds, uiutil.ReportError(err))
+			}
+		}
 	case dialog.ActionSelectReasoningEffort:
 		if m.isAgentBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
@@ -1284,15 +1320,17 @@ func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.Se
 	var (
 		dlg dialog.Dialog
 		cmd tea.Cmd
+
+		isOnboarding = m.state == uiOnboarding
 	)
 
 	switch provider.ID {
 	case "hyper":
-		dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
+		dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
 	case catwalk.InferenceProviderCopilot:
-		dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
+		dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
 	default:
-		dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
+		dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
 	}
 
 	if m.dialog.ContainsDialog(dlg.ID()) {
@@ -1648,12 +1686,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		header := uv.NewStyledString(m.header)
 		header.Draw(scr, layout.header)
 
-		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
-			Height(layout.main.Dy()).
-			Background(lipgloss.ANSIColor(rand.Intn(256))).
-			Render(" Onboarding ")
-		main := uv.NewStyledString(mainView)
-		main.Draw(scr, layout.main)
+		// NOTE: Onboarding flow will be rendered as dialogs below, but
+		// positioned at the bottom left of the screen.
 
 	case uiInitialize:
 		header := uv.NewStyledString(m.header)
@@ -1697,11 +1731,14 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		}
 	}
 
+	isOnboarding := m.state == uiOnboarding
+
 	// Add status and help layer
+	m.status.SetHideHelp(isOnboarding)
 	m.status.Draw(scr, layout.status)
 
 	// Draw completions popup if open
-	if m.completionsOpen && m.completions.HasItems() {
+	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
 		w, h := m.completions.Size()
 		x := m.completionsPositionStart.X
 		y := m.completionsPositionStart.Y - h
@@ -2606,7 +2643,8 @@ func (m *UI) openModelsDialog() tea.Cmd {
 		return nil
 	}
 
-	modelsDialog, err := dialog.NewModels(m.com)
+	isOnboarding := m.state == uiOnboarding
+	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
 	if err != nil {
 		return uiutil.ReportError(err)
 	}