Detailed changes
@@ -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]
@@ -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) {
@@ -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(".")
@@ -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)
}
@@ -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
@@ -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
}
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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
@@ -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() {
@@ -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)
}