@@ -33,6 +33,7 @@ const (
3333 stateInput
3434 stateQuerying
3535 statePermission
36+ stateLogin // interactive login wizard
3637)
3738
3839// Tea messages
@@ -130,6 +131,19 @@ type Model struct {
130131
131132 // Queued messages (typed during query execution)
132133 queuedMessages []string
134+
135+ // Login wizard state
136+ loginWizard * LoginWizard
137+ }
138+
139+ // LoginWizard tracks the multi-step login flow
140+ type LoginWizard struct {
141+ Step int // 0=provider, 1=apikey, 2=baseurl, 3=model, 4=name
142+ Provider string // "anthropic", "openai", "openrouter", "custom"
143+ ProviderName string // display name (for custom)
144+ APIKey string
145+ BaseURL string
146+ Model string
133147}
134148
135149// DisplayBlock represents one visual block in the conversation
@@ -485,8 +499,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
485499 case statePermission :
486500 return m .handlePermissionKey (msg )
487501 case stateQuerying :
488- // Allow scrolling and typing during query
489502 return m .handleQueryingKey (msg )
503+ case stateLogin :
504+ return m .handleLoginKey (msg )
490505 }
491506
492507 return m , nil
@@ -664,8 +679,15 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
664679 Type : "system" , Content : "Vim mode toggled (visual indicator only in this version)" , Timestamp : time .Now (),
665680 })
666681 }
682+ // Start login wizard
683+ if result .StartLogin {
684+ m .loginWizard = & LoginWizard {Step : 0 }
685+ m .state = stateLogin
686+ m .input .Focus ()
687+ m .refreshViewport ()
688+ return m , nil
689+ }
667690 m .refreshViewport ()
668- // If the command produced a prompt for the agent, send it
669691 if result .SkillPrompt != "" {
670692 return m , m .sendQuery (result .SkillPrompt )
671693 }
@@ -902,6 +924,8 @@ func (m *Model) View() string {
902924 if len (m .queuedMessages ) > 0 {
903925 b .WriteString (theme .DimText .Render (fmt .Sprintf (" (%d queued)" , len (m .queuedMessages ))))
904926 }
927+ case stateLogin :
928+ b .WriteString (m .renderLoginWizard ())
905929 case stateInit :
906930 b .WriteString (fmt .Sprintf (" %s Initializing..." , m .spinner .View ()))
907931 default :
@@ -1463,6 +1487,194 @@ func (m *Model) SendPrompt(prompt string) {
14631487 // Handled via SkillPrompt in handleInputKey
14641488}
14651489
1490+ // ─── Login Wizard ───────────────────────────────
1491+
1492+ func (m * Model ) handleLoginKey (msg tea.KeyMsg ) (tea.Model , tea.Cmd ) {
1493+ if m .loginWizard == nil {
1494+ m .state = stateInput
1495+ return m , nil
1496+ }
1497+
1498+ // Esc to cancel
1499+ if msg .String () == "esc" {
1500+ m .loginWizard = nil
1501+ m .state = stateInput
1502+ m .input .Reset ()
1503+ m .blocks = append (m .blocks , DisplayBlock {
1504+ Type : "system" , Content : "Login cancelled." , Timestamp : time .Now (),
1505+ })
1506+ m .refreshViewport ()
1507+ return m , nil
1508+ }
1509+
1510+ w := m .loginWizard
1511+
1512+ switch w .Step {
1513+ case 0 : // Provider selection (1-4)
1514+ switch msg .String () {
1515+ case "1" :
1516+ w .Provider = "anthropic"
1517+ w .BaseURL = "https://api.anthropic.com"
1518+ w .Step = 1
1519+ m .input .Reset ()
1520+ case "2" :
1521+ w .Provider = "openai"
1522+ w .BaseURL = "https://api.openai.com/v1"
1523+ w .Step = 1
1524+ m .input .Reset ()
1525+ case "3" :
1526+ w .Provider = "openrouter"
1527+ w .BaseURL = "https://openrouter.ai/api"
1528+ w .Step = 1
1529+ m .input .Reset ()
1530+ case "4" :
1531+ w .Provider = "custom"
1532+ w .Step = 4 // name first
1533+ m .input .Reset ()
1534+ }
1535+ m .refreshViewport ()
1536+ return m , nil
1537+
1538+ case 1 : // API Key
1539+ submitted , cmd := m .input .Update (msg )
1540+ if submitted {
1541+ w .APIKey = m .input .Value ()
1542+ m .input .Reset ()
1543+ if w .Provider == "custom" {
1544+ w .Step = 2 // base URL
1545+ } else {
1546+ w .Step = 3 // model
1547+ }
1548+ m .refreshViewport ()
1549+ return m , nil
1550+ }
1551+ return m , cmd
1552+
1553+ case 2 : // Base URL (custom only)
1554+ submitted , cmd := m .input .Update (msg )
1555+ if submitted {
1556+ w .BaseURL = m .input .Value ()
1557+ m .input .Reset ()
1558+ w .Step = 3 // model
1559+ m .refreshViewport ()
1560+ return m , nil
1561+ }
1562+ return m , cmd
1563+
1564+ case 3 : // Model name
1565+ submitted , cmd := m .input .Update (msg )
1566+ if submitted {
1567+ w .Model = m .input .Value ()
1568+ m .input .Reset ()
1569+ // Done! Save config
1570+ m .finishLogin ()
1571+ return m , nil
1572+ }
1573+ return m , cmd
1574+
1575+ case 4 : // Provider name (custom)
1576+ submitted , cmd := m .input .Update (msg )
1577+ if submitted {
1578+ w .ProviderName = m .input .Value ()
1579+ m .input .Reset ()
1580+ w .Step = 1 // API key
1581+ m .refreshViewport ()
1582+ return m , nil
1583+ }
1584+ return m , cmd
1585+ }
1586+
1587+ return m , nil
1588+ }
1589+
1590+ func (m * Model ) finishLogin () {
1591+ w := m .loginWizard
1592+
1593+ result := slash .SaveProviderConfig (w .Provider , w .APIKey , w .BaseURL , w .Model )
1594+
1595+ m .blocks = append (m .blocks , DisplayBlock {
1596+ Type : "system" , Content : result .Message , Timestamp : time .Now (),
1597+ })
1598+
1599+ m .loginWizard = nil
1600+ m .state = stateInput
1601+ m .input .Focus ()
1602+ m .refreshViewport ()
1603+ }
1604+
1605+ func (m * Model ) renderLoginWizard () string {
1606+ if m .loginWizard == nil {
1607+ return ""
1608+ }
1609+
1610+ w := m .loginWizard
1611+ boxStyle := lipgloss .NewStyle ().
1612+ Border (lipgloss .RoundedBorder ()).
1613+ BorderForeground (theme .Secondary ).
1614+ Padding (0 , 1 ).
1615+ MarginLeft (2 ).
1616+ Width (60 )
1617+
1618+ title := theme .SecondaryText .Bold (true ).Render ("Login Setup" )
1619+ var content string
1620+
1621+ switch w .Step {
1622+ case 0 :
1623+ content = title + "\n \n " +
1624+ " Select a provider:\n \n " +
1625+ theme .PrimaryText .Render (" [1]" ) + " Anthropic (Claude)\n " +
1626+ theme .PrimaryText .Render (" [2]" ) + " OpenAI (GPT)\n " +
1627+ theme .PrimaryText .Render (" [3]" ) + " OpenRouter (200+ models)\n " +
1628+ theme .PrimaryText .Render (" [4]" ) + " Custom provider\n \n " +
1629+ theme .DimText .Render (" Press 1-4 to select, Esc to cancel" )
1630+
1631+ case 1 :
1632+ provLabel := w .Provider
1633+ if w .ProviderName != "" {
1634+ provLabel = w .ProviderName
1635+ }
1636+ content = title + fmt .Sprintf (" — %s" , provLabel ) + "\n \n " +
1637+ " Enter your API key:\n \n " +
1638+ m .input .View () + "\n \n " +
1639+ theme .DimText .Render (" Press Enter to continue, Esc to cancel" )
1640+
1641+ case 2 :
1642+ content = title + " — Base URL\n \n " +
1643+ " Enter the API base URL:\n " +
1644+ theme .DimText .Render (" (e.g., https://api.example.com/v1)" ) + "\n \n " +
1645+ m .input .View () + "\n \n " +
1646+ theme .DimText .Render (" Press Enter to continue, Esc to cancel" )
1647+
1648+ case 3 :
1649+ defaultModel := ""
1650+ switch w .Provider {
1651+ case "anthropic" :
1652+ defaultModel = "sonnet-4-6"
1653+ case "openai" :
1654+ defaultModel = "gpt-4o"
1655+ case "openrouter" :
1656+ defaultModel = "anthropic/claude-sonnet-4-5"
1657+ }
1658+ hint := ""
1659+ if defaultModel != "" {
1660+ hint = fmt .Sprintf ("\n %s" , theme .DimText .Render ("(e.g., " + defaultModel + ")" ))
1661+ }
1662+ content = title + " — Model\n \n " +
1663+ " Enter the default model name:" + hint + "\n \n " +
1664+ m .input .View () + "\n \n " +
1665+ theme .DimText .Render (" Press Enter to finish, Esc to cancel" )
1666+
1667+ case 4 :
1668+ content = title + " — Custom Provider\n \n " +
1669+ " Enter provider name:\n " +
1670+ theme .DimText .Render (" (e.g., deepseek, together, groq)" ) + "\n \n " +
1671+ m .input .View () + "\n \n " +
1672+ theme .DimText .Render (" Press Enter to continue, Esc to cancel" )
1673+ }
1674+
1675+ return boxStyle .Render (content )
1676+ }
1677+
14661678// ─── Conversation Persistence ───────────────────
14671679
14681680func (m * Model ) saveConversation () {
0 commit comments