diff --git a/go.mod b/go.mod index b21855a..da9c004 100644 --- a/go.mod +++ b/go.mod @@ -20,10 +20,13 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/huh v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect @@ -43,6 +46,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect diff --git a/go.sum b/go.sum index 7aa134f..d6cab31 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -22,6 +24,8 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -32,6 +36,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -89,6 +95,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/pkg/tui/app.go b/pkg/tui/app.go index 76ab4e0..97adf7e 100644 --- a/pkg/tui/app.go +++ b/pkg/tui/app.go @@ -1574,7 +1574,7 @@ func (m *Model) updateCommit(msg tea.Msg) (tea.Model, tea.Cmd) { } case "ctrl+g": // AI generate commit message from staged diff - if m.commitDlg.State == views.CommitInputSubject || m.commitDlg.State == views.CommitInputBody { + if m.commitDlg.State == views.CommitForm { go m.generateAICommitMsg() return m, nil } diff --git a/pkg/tui/app_commitai.go b/pkg/tui/app_commitai.go index 30ff21d..19091db 100644 --- a/pkg/tui/app_commitai.go +++ b/pkg/tui/app_commitai.go @@ -127,8 +127,5 @@ func (m *Model) populateCommitFromAI(msg string) { body = strings.TrimSpace(parts[1]) } - m.commitDlg.Subject.SetValue(subject) - if body != "" { - m.commitDlg.Body.SetValue(body) - } + m.commitDlg.SetAIMessage(subject, body) } diff --git a/pkg/tui/app_config.go b/pkg/tui/app_config.go index ffc797c..1b983c5 100644 --- a/pkg/tui/app_config.go +++ b/pkg/tui/app_config.go @@ -22,33 +22,31 @@ type configResultMsg struct { // updateConfig handles the config dialog lifecycle. func (m *Model) updateConfig(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: + if km, ok := msg.(tea.KeyMsg); ok { // Detect bracketed paste (Ctrl+Shift+V) — handle BEFORE normal key processing // to avoid double-inserting characters. - if msg.Paste && m.configDlg.State == views.ConfigInput { - m.configDlg.HandlePaste(string(msg.Runes)) + if km.Paste && m.configDlg.State == views.ConfigInput { + m.configDlg.HandlePaste(string(km.Runes)) return m, nil } + } - key := msg.String() - handled := m.configDlg.HandleKey(key) - if !handled { - return m, nil - } + updated, cmd := m.configDlg.Update(msg) + m.configDlg = *updated - // On enter in loading state, process selection/value - if key == "enter" && m.configDlg.State == views.ConfigLoading { + if key, ok := msg.(tea.KeyMsg); ok { + // A completed step lands in loading state — process it. + if key.String() == "enter" && m.configDlg.State == views.ConfigLoading { m.processConfigAction() - return m, nil + return m, cmd } // On enter in result state, advance or close - if key == "enter" && m.configDlg.State == views.ConfigResult && m.configDlg.Success { + if key.String() == "enter" && m.configDlg.State == views.ConfigResult && m.configDlg.Success { // If "saved" result during provider config, advance to next step if m.configDlg.Result == "saved" && m.configDlg.TargetProvider != "" { m.advanceProviderConfig() - return m, nil + return m, cmd } } @@ -56,9 +54,14 @@ func (m *Model) updateConfig(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.configDlg.Active() { m.mode = modeNormal } + return m, cmd } - return m, nil + // Non-key messages (e.g. resize): exit mode if closed. + if !m.configDlg.Active() { + m.mode = modeNormal + } + return m, cmd } // processConfigAction handles Enter at the saving step. @@ -80,9 +83,9 @@ func (m *Model) processConfigAction() { case views.FieldAIProviderMenu: item := m.configDlg.GetSelectedItem() - // Skip separator line + // Skip separator line (rebuild the step so the form stays live) if item == "───" { - m.configDlg.State = views.ConfigSelect // stay in select + m.configDlg.RefreshForm() return } if item == "Add custom provider..." { @@ -125,12 +128,7 @@ func (m *Model) processConfigAction() { item := m.configDlg.GetSelectedItem() if views.IsCustomModelOption(item) { // Open free-form text input for custom model - m.configDlg.State = views.ConfigInput - m.configDlg.Field = views.FieldAIModel - m.configDlg.Input = "" - m.configDlg.Cursor = 0 - m.configDlg.Result = "" - m.configDlg.Success = false + m.configDlg.OpenCustomModelInput(m.configDlg.TargetProvider) return } m.saveProviderModel() diff --git a/pkg/tui/views/commit.go b/pkg/tui/views/commit.go index 288028a..2b165f3 100644 --- a/pkg/tui/views/commit.go +++ b/pkg/tui/views/commit.go @@ -4,9 +4,8 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/zaidejjo/zgit/pkg/tui/styles" ) @@ -15,9 +14,11 @@ import ( type CommitDialogState int const ( - CommitInputSubject CommitDialogState = iota - CommitInputBody + // CommitForm is the huh form (subject + body with validation). + CommitForm CommitDialogState = iota + // CommitConfirming shows the final review before executing. CommitConfirming + // CommitResult shows the outcome. CommitResult ) @@ -29,45 +30,61 @@ type CommitResultInfo struct { Error string } -// CommitModel is an interactive commit message dialog. +// CommitModel is a huh-backed commit message dialog. +// The form owns input navigation and validation; the app owns +// confirmation execution and result display (unchanged contract). type CommitModel struct { - Subject textinput.Model - Body textarea.Model - State CommitDialogState - Result CommitResultInfo - width int - height int - active bool + form *huh.Form + subject string + body string + State CommitDialogState + Result CommitResultInfo + width int + height int + active bool + needInit bool +} + +// ValidateCommitSubject rejects empty and overlong subjects. +func ValidateCommitSubject(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("summary is required") + } + if len([]rune(s)) > 72 { + return fmt.Errorf("keep the summary under 72 chars") + } + return nil } // NewCommitModel creates a new commit dialog. func NewCommitModel() CommitModel { - subject := textinput.New() - subject.Placeholder = "Commit summary (required)" - subject.Focus() - subject.CharLimit = 72 - subject.Width = 60 - subject.Prompt = "─ " - subject.TextStyle = lipgloss.NewStyle().Foreground(styles.Text) - subject.PlaceholderStyle = lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true) - - body := textarea.New() - body.Placeholder = "Optional detailed description (Ctrl+D to finish)" - body.CharLimit = 0 - body.SetWidth(60) - body.SetHeight(8) - body.ShowLineNumbers = false - body.Prompt = "" - body.FocusedStyle.Text = lipgloss.NewStyle().Foreground(styles.Text) - body.BlurredStyle.Text = lipgloss.NewStyle().Foreground(styles.Text) - body.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true) - body.BlurredStyle.Placeholder = lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true) - - return CommitModel{ - Subject: subject, - Body: body, - State: CommitInputSubject, - active: true, + m := CommitModel{State: CommitForm, active: true} + m.buildForm() + m.needInit = true + return m +} + +func (m *CommitModel) buildForm() { + m.form = huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Summary"). + Description("One line, imperative mood"). + Placeholder("Commit summary (required)"). + CharLimit(72). + Value(&m.subject). + Validate(ValidateCommitSubject), + huh.NewText(). + Title("Description"). + Description("Optional details"). + Placeholder("What changed and why"). + CharLimit(2000). + Lines(5). + Value(&m.body), + ), + ).WithShowHelp(true) + if m.width > 0 { + m.form = m.form.WithWidth(m.width) } } @@ -79,23 +96,30 @@ func (m *CommitModel) Active() bool { // Open activates the dialog and resets state. func (m *CommitModel) Open() { m.active = true - m.State = CommitInputSubject - m.Subject.SetValue("") - m.Body.SetValue("") - m.Subject.Focus() + m.State = CommitForm + m.subject = "" + m.body = "" m.Result = CommitResultInfo{} + m.buildForm() + m.needInit = true +} + +// SetAIMessage prefills the form (e.g. AI-generated message). +func (m *CommitModel) SetAIMessage(subject, body string) { + m.subject = subject + m.body = body + m.buildForm() + m.needInit = true } // Close deactivates the dialog. func (m *CommitModel) Close() { m.active = false - m.Subject.Blur() - m.Body.Blur() } // Init implements tea.Model. func (m CommitModel) Init() tea.Cmd { - return textinput.Blink + return nil } // Update handles key events for the commit dialog. @@ -103,63 +127,46 @@ func (m *CommitModel) Update(msg tea.Msg) (*CommitModel, tea.Cmd) { if !m.active { return m, nil } + if m.needInit { + m.needInit = false + if m.form != nil { + return m, m.form.Init() + } + return m, nil + } switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.Subject.Width = msg.Width - 20 - m.Body.SetWidth(msg.Width - 20) + if m.form != nil { + m.form = m.form.WithWidth(msg.Width - 4) + } + return m, nil case tea.KeyMsg: - switch msg.String() { - case "esc": - // Cancel at any state + // Esc always cancels the dialog (huh never sees it). + if msg.String() == "esc" { m.Close() return m, nil - - case "ctrl+d": - // Finish body input - if m.State == CommitInputBody { - m.State = CommitConfirming - return m, nil - } - - case "enter": - switch m.State { - case CommitInputSubject: - subject := strings.TrimSpace(m.Subject.Value()) - if subject == "" { - m.Result = CommitResultInfo{ - Success: false, - Error: "Commit message cannot be empty", - } - m.State = CommitResult - return m, nil - } - // Move to body - m.State = CommitInputBody - m.Subject.Blur() - m.Body.Focus() - return m, nil - } - // CommitConfirming + enter handled at app level } } - // Delegate to active input - var cmd tea.Cmd - switch m.State { - case CommitInputSubject: - updatedSubject, subjectCmd := m.Subject.Update(msg) - m.Subject = updatedSubject - cmd = subjectCmd - case CommitInputBody: - updatedBody, bodyCmd := m.Body.Update(msg) - m.Body = updatedBody - cmd = bodyCmd + if m.form == nil { + return m, nil + } + f, cmd := m.form.Update(msg) + if nf, ok := f.(*huh.Form); ok { + m.form = nf + } + if m.form.State == huh.StateCompleted { + m.State = CommitConfirming + return m, nil + } + if m.form.State == huh.StateAborted { + m.Close() + return m, nil } - return m, cmd } @@ -170,79 +177,68 @@ func (m *CommitModel) View(width int) string { } var b strings.Builder - - b.WriteString(styles.DialogBoxStyle.Render(m.renderContent(width))) - return b.String() + b.WriteString(styles.DialogTitleStyle.Render(" Commit Changes")) + b.WriteString("\n\n") + if m.State == CommitResult { + b.WriteString(m.renderResult()) + return styles.DialogBoxStyle.Render(b.String()) + } + if m.State == CommitConfirming { + b.WriteString(m.renderConfirm()) + return styles.DialogBoxStyle.Render(b.String()) + } + if m.form != nil { + b.WriteString(m.form.View()) + } + return styles.DialogBoxStyle.Render(b.String()) } -// renderContent returns the inner content of the commit dialog. -func (m *CommitModel) renderContent(width int) string { +// renderConfirm shows the final review before executing. +func (m *CommitModel) renderConfirm() string { var b strings.Builder - - b.WriteString(styles.DialogTitleStyle.Render(" Commit Changes")) + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Summary:")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + m.subject)) b.WriteString("\n\n") - - switch m.State { - case CommitInputSubject: - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Summary (required):")) - b.WriteString("\n") - b.WriteString(m.Subject.View()) - b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter to continue, Esc to cancel")) - - case CommitInputBody: - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Summary:")) + if body := strings.TrimSpace(m.body); body != "" { + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Description:")) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + m.Subject.Value())) + b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + body)) b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Description (optional):")) - b.WriteString("\n") - b.WriteString(m.Body.View()) - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Ctrl+D to finish, Esc to cancel")) + } + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter to confirm commit, Esc to cancel")) + return b.String() +} - case CommitConfirming: - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Summary:")) - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + m.Subject.Value())) - b.WriteString("\n\n") - if body := strings.TrimSpace(m.Body.Value()); body != "" { - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Description:")) +// renderResult shows the commit outcome. +func (m *CommitModel) renderResult() string { + var b strings.Builder + if m.Result.Success { + b.WriteString(styles.StatusStagedStyle.Render(" ✓ Commit successful")) + if m.Result.Hash != "" { b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + body)) - b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" " + m.Result.Hash)) } - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter to confirm commit, Esc to cancel")) - - case CommitResult: - if m.Result.Success { - b.WriteString(styles.StatusStagedStyle.Render(" ✓ Commit successful")) - if m.Result.Hash != "" { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" " + m.Result.Hash)) - } - if m.Result.Error != "" { - b.WriteString("\n") - b.WriteString(styles.ErrorStyle.Render(" ⚠ " + m.Result.Error)) - } - } else { - b.WriteString(styles.ErrorStyle.Render(" ✗ Commit failed")) - if m.Result.Error != "" { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Red).Render(" " + m.Result.Error)) - } + if m.Result.Error != "" { + b.WriteString("\n") + b.WriteString(styles.ErrorStyle.Render(" ⚠ " + m.Result.Error)) + } + } else { + b.WriteString(styles.ErrorStyle.Render(" ✗ Commit failed")) + if m.Result.Error != "" { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(styles.Red).Render(" " + m.Result.Error)) } - b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter or Esc to close")) } - + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter or Esc to close")) return b.String() } // GetMessage returns the composed commit message (subject + body). func (m *CommitModel) GetMessage() string { - subject := strings.TrimSpace(m.Subject.Value()) - body := strings.TrimSpace(m.Body.Value()) + subject := strings.TrimSpace(m.subject) + body := strings.TrimSpace(m.body) if body == "" { return subject } diff --git a/pkg/tui/views/config.go b/pkg/tui/views/config.go index b6db315..7918dfe 100644 --- a/pkg/tui/views/config.go +++ b/pkg/tui/views/config.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/zaidejjo/zgit/pkg/tui/styles" ) @@ -13,8 +15,8 @@ type ConfigState int const ( ConfigClosed ConfigState = iota - ConfigInput // entering a value - ConfigSelect // selecting from a list + ConfigInput // entering a value (huh input form) + ConfigSelect // selecting from a list (huh select form) ConfigLoading // validating (e.g. fetching user from token) ConfigResult // showing success/error ) @@ -53,7 +55,7 @@ var KnownProviders = []KnownProvider{ {Name: "custom", DefaultModel: "", DefaultEndpoint: ""}, } -// ProviderModels maps provider names to recommended/fetchable models. +// ProviderModelOptions maps provider names to recommended/fetchable models. var ProviderModelOptions = map[string][]string{ "openrouter": {"deepseek/deepseek-r1", "anthropic/claude-3.5-sonnet", "openai/gpt-4o", "google/gemini-2.5-flash"}, "openai": {"gpt-4o", "gpt-4o-mini", "o1", "o3-mini"}, @@ -74,21 +76,30 @@ func IsCustomModelOption(item string) bool { } // ConfigModel is an overlay for editing config values with multi-step provider support. +// Each step builds a single-field huh form; the app orchestrator (app_config.go) +// advances steps exactly as before (completion == old Enter semantics). type ConfigModel struct { State ConfigState Field ConfigField - Input string + Input string // last completed input value (GetInput compat) Cursor int Result string Success bool Width int - // List selection state + // List selection state (SelectItems builds the form; SelectCursor is + // synced on completion so GetSelectedItem keeps working). SelectItems []string // items for selection list SelectCursor int // Provider being configured (set when entering provider-specific fields) TargetProvider string + + form *huh.Form + inputVal string // huh-bound input value + selectVal string // huh-bound select value + inputMode bool // true when the current step is a text input + needInit bool } // NewConfigModel creates a default config dialog. @@ -96,15 +107,66 @@ func NewConfigModel() ConfigModel { return ConfigModel{} } +// buildInput creates the text-entry form for the current Field. +func (m *ConfigModel) buildInput(prompt, placeholder string, password, allowEmpty bool) { + m.inputMode = true + field := huh.NewInput(). + Title(prompt). + Placeholder(placeholder). + Value(&m.inputVal) + if password { + field = field.EchoMode(huh.EchoModePassword) + } + if !allowEmpty { + field = field.Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("a value is required") + } + return nil + }) + } + m.form = huh.NewForm(huh.NewGroup(field)).WithShowHelp(true) + m.applyWidth() + m.needInit = true +} + +// buildSelect creates the list form from SelectItems (values == labels so +// GetSelectedItem keeps returning the exact legacy strings). +func (m *ConfigModel) buildSelect(title string) { + m.inputMode = false + opts := make([]huh.Option[string], 0, len(m.SelectItems)) + for _, item := range m.SelectItems { + opts = append(opts, huh.NewOption(item, item)) + } + m.form = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(title). + Key("pick"). + Options(opts...). + Value(&m.selectVal), + ), + ).WithShowHelp(true) + m.applyWidth() + m.needInit = true +} + +func (m *ConfigModel) applyWidth() { + if m.form != nil && m.Width > 0 { + m.form = m.form.WithWidth(m.Width - 4) + } +} + // OpenGitHubToken starts editing the GitHub token. func (m *ConfigModel) OpenGitHubToken() { m.State = ConfigInput m.Field = FieldGitHubToken + m.inputVal = "" m.Input = "" - m.Cursor = 0 m.Result = "" m.Success = false m.TargetProvider = "" + m.buildInput("GitHub Token", "ghp_… / github_pat_…", true, false) } // OpenAIProviderList shows the provider menu for selecting which to configure. @@ -121,6 +183,7 @@ func (m *ConfigModel) OpenAIProviderList(configuredProviders []string) { m.Field = FieldAIProviderList m.Result = "" m.Success = false + m.buildSelect("AI Providers") } // OpenAIProviderPicker shows the featured providers list + "Add custom provider...". @@ -140,6 +203,7 @@ func (m *ConfigModel) OpenAIProviderPicker() { m.Field = FieldAIProviderMenu m.Result = "" m.Success = false + m.buildSelect("Pick a provider") } // OpenAIProviderFullList shows all known providers (for "Add custom provider..." flow). @@ -154,6 +218,7 @@ func (m *ConfigModel) OpenAIProviderFullList() { m.Field = FieldAIProviderMenu m.Result = "" m.Success = false + m.buildSelect("Pick a provider") } // OpenAIProviderActions shows actions for an already-configured provider. @@ -171,6 +236,7 @@ func (m *ConfigModel) OpenAIProviderActions(provider string) { m.Field = FieldAIProviderActions m.Result = "" m.Success = false + m.buildSelect(provider) } // OpenAIProviderKey starts editing the API key for a specific provider. @@ -178,10 +244,11 @@ func (m *ConfigModel) OpenAIProviderKey(provider string) { m.State = ConfigInput m.Field = FieldAIAPIKey m.TargetProvider = provider + m.inputVal = "" m.Input = "" - m.Cursor = 0 m.Result = "" m.Success = false + m.buildInput(provider+" API Key", "sk-…", true, false) } // OpenAIProviderModel opens model selection for a provider. @@ -204,13 +271,7 @@ func (m *ConfigModel) OpenAIProviderModel(provider string, options []string, cus if len(merged) == 0 && len(customModels) == 0 { // No known options and no saved custom models — free-form input - m.State = ConfigInput - m.Field = FieldAIModel - m.TargetProvider = provider - m.Input = "" - m.Cursor = 0 - m.Result = "" - m.Success = false + m.openCustomModelInput(provider) return } @@ -223,6 +284,23 @@ func (m *ConfigModel) OpenAIProviderModel(provider string, options []string, cus m.TargetProvider = provider m.Result = "" m.Success = false + m.buildSelect(provider + " Model") +} + +// OpenCustomModelInput opens free-form model entry (custom model flow). +func (m *ConfigModel) OpenCustomModelInput(provider string) { + m.openCustomModelInput(provider) +} + +func (m *ConfigModel) openCustomModelInput(provider string) { + m.State = ConfigInput + m.Field = FieldAIModel + m.TargetProvider = provider + m.inputVal = "" + m.Input = "" + m.Result = "" + m.Success = false + m.buildInput(provider+" Model", "model id", false, false) } // OpenAIProviderEndpoint opens endpoint editing for a provider. @@ -230,10 +308,11 @@ func (m *ConfigModel) OpenAIProviderEndpoint(provider string, defaultEndpoint st m.State = ConfigInput m.Field = FieldAIEndpoint m.TargetProvider = provider + m.inputVal = defaultEndpoint m.Input = defaultEndpoint - m.Cursor = len(m.Input) m.Result = "" m.Success = false + m.buildInput(provider+" Endpoint", "https://…", false, true) } // Active returns true if dialog is visible. @@ -241,14 +320,13 @@ func (m *ConfigModel) Active() bool { return m.State != ConfigClosed } -// HandlePaste inserts pasted text at cursor position. Called from app level on tea.PasteMsg. +// HandlePaste appends pasted text to the input value. Called from app level +// on tea paste messages. func (m *ConfigModel) HandlePaste(text string) { - if m.State != ConfigInput { + if m.State != ConfigInput || !m.inputMode { return } - // Insert text at cursor, preserving masked display - m.Input = m.Input[:m.Cursor] + text + m.Input[m.Cursor:] - m.Cursor += len(text) + m.inputVal += text } // Close dismisses the dialog. @@ -256,6 +334,7 @@ func (m *ConfigModel) Close() { m.State = ConfigClosed m.TargetProvider = "" m.SelectItems = nil + m.form = nil } // GetInput returns trimmed input. @@ -265,6 +344,9 @@ func (m *ConfigModel) GetInput() string { // GetSelectedItem returns the currently selected list item. func (m *ConfigModel) GetSelectedItem() string { + if m.selectVal != "" { + return m.selectVal + } if m.SelectCursor >= 0 && m.SelectCursor < len(m.SelectItems) { return m.SelectItems[m.SelectCursor] } @@ -279,98 +361,143 @@ func (m *ConfigModel) GetSelectedProvider() string { return item } -// HandleKey processes key presses. Returns true if handled. -func (m *ConfigModel) HandleKey(key string) bool { - switch m.State { - case ConfigInput: - return m.handleInputKey(key) - case ConfigSelect: - return m.handleSelectKey(key) - case ConfigLoading: - return true - case ConfigResult: - switch key { - case "esc", "enter": +// Update drives the huh form. Completion maps to ConfigLoading (the app +// orchestrator picks it up exactly like the old Enter key did). +func (m *ConfigModel) Update(msg tea.Msg) (*ConfigModel, tea.Cmd) { + if !m.Active() { + return m, nil + } + if m.needInit { + m.needInit = false + if m.form != nil { + return m, m.form.Init() + } + return m, nil + } + + // Loading and Result states swallow input except close keys. + if m.State == ConfigLoading { + return m, nil + } + if m.State == ConfigResult { + if key, ok := msg.(tea.KeyMsg); ok && (key.String() == "esc" || key.String() == "enter") { m.Close() - return true } + return m, nil } - return true -} -func (m *ConfigModel) handleInputKey(key string) bool { - switch key { - case "esc": - // Go back to provider list if configuring a provider - if m.TargetProvider != "" && m.Field != FieldGitHubToken { - // Signal to parent to go back to provider list - m.State = ConfigResult - m.Success = true - m.Result = "saved" - return true + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Width = msg.Width + m.applyWidth() + return m, nil + + case tea.KeyMsg: + // Esc cancels — or steps back to the provider list mid-flow + // (preserves the legacy "Esc to go back" signal). + if msg.String() == "esc" { + if m.inputMode && m.TargetProvider != "" && m.Field != FieldGitHubToken { + m.State = ConfigResult + m.Success = true + m.Result = "saved" + return m, nil + } + m.Close() + return m, nil } + } + + if m.form == nil { + return m, nil + } + f, cmd := m.form.Update(msg) + if nf, ok := f.(*huh.Form); ok { + m.form = nf + } + if m.form.State == huh.StateCompleted { + m.completeStep() + return m, nil + } + if m.form.State == huh.StateAborted { m.Close() - return true - case "enter": - if m.GetInput() != "" || m.Field == FieldAIEndpoint { - m.State = ConfigLoading - return true - } - return true - case "backspace": - if m.Cursor > 0 { - m.Input = m.Input[:m.Cursor-1] + m.Input[m.Cursor:] - m.Cursor-- - } - case "left": - if m.Cursor > 0 { - m.Cursor-- - } - case "right": - if m.Cursor < len(m.Input) { - m.Cursor++ - } - case "home": - m.Cursor = 0 - case "end": - m.Cursor = len(m.Input) - case " ": - m.Input = m.Input[:m.Cursor] + " " + m.Input[m.Cursor:] - m.Cursor++ + return m, nil + } + return m, cmd +} + +// RefreshForm rebuilds the current step's form (used when staying on a +// step, e.g. separator rows that complete without choosing). +func (m *ConfigModel) RefreshForm() { + switch { + case m.inputMode: + m.buildInput(m.inputTitle(), m.inputPlaceholder(), m.inputPassword(), m.inputAllowEmpty()) default: - if len(key) == 1 && key[0] >= 32 && key[0] < 127 { - m.Input = m.Input[:m.Cursor] + key + m.Input[m.Cursor:] - m.Cursor++ - } + m.buildSelect(m.selectTitle()) } - return true } -func (m *ConfigModel) handleSelectKey(key string) bool { - switch key { - case "esc": - m.Close() - return true - case "enter": - if m.SelectCursor >= 0 && m.SelectCursor < len(m.SelectItems) { - m.State = ConfigLoading - return true - } - return true - case "j", "down": - if m.SelectCursor < len(m.SelectItems)-1 { - m.SelectCursor++ - } - case "k", "up": - if m.SelectCursor > 0 { - m.SelectCursor-- +func (m *ConfigModel) inputTitle() string { + switch m.Field { + case FieldGitHubToken: + return "GitHub Token" + case FieldAIAPIKey: + return m.TargetProvider + " API Key" + case FieldAIModel: + return m.TargetProvider + " Model" + default: + return m.TargetProvider + " Endpoint" + } +} + +func (m *ConfigModel) inputPlaceholder() string { + switch m.Field { + case FieldGitHubToken: + return "ghp_… / github_pat_…" + case FieldAIAPIKey: + return "sk-…" + case FieldAIModel: + return "model id" + default: + return "https://…" + } +} + +func (m *ConfigModel) inputPassword() bool { + return m.Field == FieldGitHubToken || m.Field == FieldAIAPIKey +} + +func (m *ConfigModel) inputAllowEmpty() bool { + return m.Field == FieldAIEndpoint +} + +func (m *ConfigModel) selectTitle() string { + switch m.Field { + case FieldAIProviderList: + return "AI Providers" + case FieldAIProviderMenu: + return "Pick a provider" + case FieldAIProviderActions: + return m.TargetProvider + default: + return m.TargetProvider + " Model" + } +} + +// completeStep syncs form values and advances to ConfigLoading so the +// orchestrator processes the step (same as the legacy Enter key). +func (m *ConfigModel) completeStep() { + if m.inputMode { + m.Input = m.inputVal + } else { + m.Input = "" + for i, item := range m.SelectItems { + if item == m.selectVal { + m.SelectCursor = i + break + } } - case "g", "home": - m.SelectCursor = 0 - case "G", "end": - m.SelectCursor = len(m.SelectItems) - 1 } - return true + m.State = ConfigLoading } // SetResult displays the outcome. @@ -398,10 +525,18 @@ func (m *ConfigModel) View(width int) string { title := m.dialogTitle() switch m.State { - case ConfigInput: - m.renderInput(&b, dialogW, title) - case ConfigSelect: - m.renderSelect(&b, dialogW, title) + case ConfigInput, ConfigSelect: + b.WriteString(lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(styles.Mauve). + Width(dialogW). + Padding(0, 1). + Background(styles.Surface). + Render(strings.Join([]string{ + lipgloss.NewStyle().Bold(true).Foreground(styles.Mauve).Render(title), + "", + m.formView(), + }, "\n"))) case ConfigLoading: m.renderLoading(&b, dialogW, title) case ConfigResult: @@ -414,6 +549,14 @@ func (m *ConfigModel) View(width int) string { Render(b.String()) } +// formView renders the huh form (guarded for nil during transitions). +func (m *ConfigModel) formView() string { + if m.form == nil { + return styles.LoadingStyle.Render("Loading...") + } + return m.form.View() +} + func (m *ConfigModel) dialogTitle() string { prefix := "⚙" switch m.Field { @@ -443,106 +586,6 @@ func (m *ConfigModel) dialogTitle() string { return prefix + " Config" } -func (m *ConfigModel) renderInput(b *strings.Builder, dialogW int, title string) { - prompt := "" - switch m.Field { - case FieldGitHubToken: - prompt = "Enter GitHub Personal Access Token (classic or fine-grained):" - case FieldAIAPIKey: - prompt = "Enter API key:" - case FieldAIModel: - prompt = "Enter model name:" - case FieldAIEndpoint: - prompt = "Enter API endpoint URL:" - } - - display := m.Input - // Mask token/API key fields - if m.Field == FieldGitHubToken || m.Field == FieldAIAPIKey { - display = strings.Repeat("•", len(m.Input)) - if m.Cursor < len(display) { - display = display[:m.Cursor] + "█" + display[m.Cursor:] - } - } else if m.Cursor < len(display) { - display = display[:m.Cursor] + "█" + display[m.Cursor:] - } else { - display += " " - } - - inputLine := "> " + display - - hint := " Enter to confirm Esc to cancel" - if m.TargetProvider != "" { - hint = " Enter to confirm Esc to go back" - } - - b.WriteString(lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Mauve). - Width(dialogW). - Padding(0, 1). - Background(styles.Surface). - Render(strings.Join([]string{ - lipgloss.NewStyle().Bold(true).Foreground(styles.Mauve).Render(title), - "", - lipgloss.NewStyle().Foreground(styles.Subtext).Render(prompt), - inputLine, - "", - lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(hint), - }, "\n"))) -} - -func (m *ConfigModel) renderSelect(b *strings.Builder, dialogW int, title string) { - items := make([]string, 0, len(m.SelectItems)+4) - items = append(items, lipgloss.NewStyle().Bold(true).Foreground(styles.Mauve).Render(title)) - items = append(items, "") - items = append(items, lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Select an option:")) - - avail := 12 // header + hints - maxItems := dialogW/2 - 2 - if maxItems < 5 { - maxItems = 5 - } - start := 0 - if m.SelectCursor > maxItems-1 { - start = m.SelectCursor - maxItems + 1 - } - end := start + maxItems - if end > len(m.SelectItems) { - end = len(m.SelectItems) - } - - for i := start; i < end; i++ { - item := m.SelectItems[i] - if i == m.SelectCursor { - items = append(items, lipgloss.NewStyle(). - Foreground(styles.Mauve). - Bold(true). - Render("▸ "+item)) - } else { - items = append(items, lipgloss.NewStyle(). - Foreground(styles.Text). - Render(" "+item)) - } - avail++ - } - - items = append(items, "") - hint := " ↑↓ navigate Enter select Esc cancel" - if m.TargetProvider != "" { - hint = " ↑↓ navigate Enter select" - } - items = append(items, lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(hint)) - - b.WriteString(lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Mauve). - Width(dialogW). - Padding(0, 1). - Background(styles.Surface). - Render(strings.Join(items, "\n"))) -} - func (m *ConfigModel) renderLoading(b *strings.Builder, dialogW int, title string) { b.WriteString(lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). diff --git a/pkg/tui/views/newviews_test.go b/pkg/tui/views/newviews_test.go index f438bf3..71029c0 100644 --- a/pkg/tui/views/newviews_test.go +++ b/pkg/tui/views/newviews_test.go @@ -146,3 +146,59 @@ func TestPaletteFuzzyRanking(t *testing.T) { t.Fatalf("want empty, got %v", m.Filtered) } } + +func TestDialogValidators(t *testing.T) { + if ValidateCommitSubject("") == nil { + t.Errorf("empty subject should fail") + } + if ValidateCommitSubject(" ") == nil { + t.Errorf("blank subject should fail") + } + if ValidateCommitSubject(strings.Repeat("x", 73)) == nil { + t.Errorf("73-char subject should fail") + } + if ValidateCommitSubject("fix: tidy imports") != nil { + t.Errorf("valid subject should pass") + } + if ValidatePRTitle("") == nil { + t.Errorf("empty title should fail") + } + if ValidatePRTitle("Add feature") != nil { + t.Errorf("valid title should pass") + } +} + +func TestConfigStepCompletion(t *testing.T) { + m := NewConfigModel() + m.OpenAIProviderKey("openai") + if m.State != ConfigInput || m.Field != FieldAIAPIKey { + t.Fatalf("key step = %+v", m) + } + m.inputVal = "sk-test" + m.completeStep() + if m.State != ConfigLoading || m.GetInput() != "sk-test" { + t.Fatalf("after complete = %+v input=%q", m.State, m.GetInput()) + } + + m.OpenAIProviderModel("openai", []string{"gpt-4o"}, []string{"my-model"}) + if m.State != ConfigSelect { + t.Fatalf("model step should be select, = %v", m.State) + } + foundCustom := false + for _, it := range m.SelectItems { + if IsCustomModelOption(it) { + foundCustom = true + } + } + if !foundCustom { + t.Fatalf("custom sentinel missing: %v", m.SelectItems) + } + m.selectVal = "my-model" + m.completeStep() + if m.State != ConfigLoading || m.GetSelectedItem() != "my-model" { + t.Fatalf("after select = %+v item=%q", m.State, m.GetSelectedItem()) + } + if m.GetSelectedProvider() != "my-model" { + t.Fatalf("provider passthrough = %q", m.GetSelectedProvider()) + } +} diff --git a/pkg/tui/views/pr_create.go b/pkg/tui/views/pr_create.go index 26e9289..65764b9 100644 --- a/pkg/tui/views/pr_create.go +++ b/pkg/tui/views/pr_create.go @@ -4,9 +4,8 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/zaidejjo/zgit/pkg/core/github" "github.com/zaidejjo/zgit/pkg/tui/styles" @@ -16,10 +15,11 @@ import ( type PRCreateState int const ( - PRCreateInputTitle PRCreateState = iota - PRCreateInputBody - PRCreateSelectStrategy + // PRCreateForm is the huh form (title + body + strategy). + PRCreateForm PRCreateState = iota + // PRCreateConfirming shows the final review before creating. PRCreateConfirming + // PRCreateResult shows the outcome. PRCreateResult ) @@ -56,6 +56,17 @@ func (s MergeStrategy) APIValue() string { return "merge" } +// ValidatePRTitle rejects empty and overlong titles. +func ValidatePRTitle(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("title is required") + } + if len([]rune(s)) > 120 { + return fmt.Errorf("keep the title under 120 chars") + } + return nil +} + // PRCreateResultInfo holds the outcome of a PR creation. type PRCreateResultInfo struct { Success bool @@ -64,49 +75,74 @@ type PRCreateResultInfo struct { Error string } -// PRCreateModel is an interactive pull request creation dialog. +// PRCreateModel is a huh-backed pull request creation dialog. type PRCreateModel struct { - Title textinput.Model - Body textarea.Model + form *huh.Form + title string + body string + Strategy MergeStrategy // selected strategy (synced on complete) + HeadBranch string // source branch (pre-filled) + BaseBranch string // target branch (pre-filled) State PRCreateState - Strategy MergeStrategy - HeadBranch string // source branch (pre-filled) - BaseBranch string // target branch (pre-filled) Result PRCreateResultInfo width int height int active bool + needInit bool } // NewPRCreateModel creates a new PR creation dialog. func NewPRCreateModel() PRCreateModel { - title := textinput.New() - title.Placeholder = "PR title (required)" - title.Focus() - title.CharLimit = 120 - title.Width = 60 - title.Prompt = "─ " - title.TextStyle = lipgloss.NewStyle().Foreground(styles.Text) - title.PlaceholderStyle = lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true) + m := PRCreateModel{State: PRCreateForm, active: true} + m.buildForm() + m.needInit = true + return m +} - body := textarea.New() - body.Placeholder = "Optional description (Ctrl+D to finish)" - body.CharLimit = 0 - body.SetWidth(60) - body.SetHeight(8) - body.ShowLineNumbers = false - body.Prompt = "" - body.FocusedStyle.Text = lipgloss.NewStyle().Foreground(styles.Text) - body.BlurredStyle.Text = lipgloss.NewStyle().Foreground(styles.Text) - body.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true) - body.BlurredStyle.Placeholder = lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true) +func (m *PRCreateModel) buildForm() { + strategyLabel := m.Strategy.String() + m.form = huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Title"). + Description(fmt.Sprintf("Branch: %s → %s", m.HeadBranch, m.BaseBranch)). + Placeholder("PR title (required)"). + CharLimit(120). + Value(&m.title). + Validate(ValidatePRTitle), + huh.NewText(). + Title("Description"). + Placeholder("Optional description"). + CharLimit(4000). + Lines(5). + Value(&m.body), + huh.NewSelect[string](). + Title("Merge strategy"). + Key("strategy"). + Options( + huh.NewOption(MergeNormal.String(), MergeNormal.String()), + huh.NewOption(MergeSquash.String(), MergeSquash.String()), + huh.NewOption(MergeRebase.String(), MergeRebase.String()), + ). + Value(&strategyLabel). + Description("How this PR will merge"), + ), + ).WithShowHelp(true) + if m.width > 0 { + m.form = m.form.WithWidth(m.width) + } + m.syncStrategy(strategyLabel) +} - return PRCreateModel{ - Title: title, - Body: body, - State: PRCreateInputTitle, - Strategy: MergeNormal, - active: true, +// syncStrategy maps the select label back to the enum. +func (m *PRCreateModel) syncStrategy(label string) { + switch label { + case MergeSquash.String(): + m.Strategy = MergeSquash + case MergeRebase.String(): + m.Strategy = MergeRebase + default: + m.Strategy = MergeNormal } } @@ -118,26 +154,25 @@ func (m *PRCreateModel) Active() bool { // Open activates the dialog with pre-filled branch info. func (m *PRCreateModel) Open(headBranch, baseBranch string) { m.active = true - m.State = PRCreateInputTitle + m.State = PRCreateForm m.HeadBranch = headBranch m.BaseBranch = baseBranch m.Strategy = MergeNormal - m.Title.SetValue("") - m.Body.SetValue("") - m.Title.Focus() + m.title = "" + m.body = "" m.Result = PRCreateResultInfo{} + m.buildForm() + m.needInit = true } // Close deactivates the dialog. func (m *PRCreateModel) Close() { m.active = false - m.Title.Blur() - m.Body.Blur() } // Init implements tea.Model. func (m PRCreateModel) Init() tea.Cmd { - return textinput.Blink + return nil } // Update handles key events for the PR creation dialog. @@ -145,90 +180,54 @@ func (m *PRCreateModel) Update(msg tea.Msg) (*PRCreateModel, tea.Cmd) { if !m.active { return m, nil } + if m.needInit { + m.needInit = false + if m.form != nil { + return m, m.form.Init() + } + return m, nil + } switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.Title.Width = msg.Width - 20 - m.Body.SetWidth(msg.Width - 20) + if m.form != nil { + m.form = m.form.WithWidth(msg.Width - 4) + } + return m, nil case tea.KeyMsg: - switch msg.String() { - case "esc": + if msg.String() == "esc" { m.Close() return m, nil - - case "ctrl+d": - if m.State == PRCreateInputBody { - m.State = PRCreateSelectStrategy - m.Title.Blur() - m.Body.Blur() - return m, nil - } - - case "enter": - switch m.State { - case PRCreateInputTitle: - title := strings.TrimSpace(m.Title.Value()) - if title == "" { - m.Result = PRCreateResultInfo{ - Success: false, - Error: "PR title cannot be empty", - } - m.State = PRCreateResult - return m, nil - } - m.State = PRCreateInputBody - m.Title.Blur() - m.Body.Focus() - return m, nil - - case PRCreateSelectStrategy: - // Enter confirms the strategy selection - m.State = PRCreateConfirming - return m, nil - } - - case "up", "left": - if m.State == PRCreateSelectStrategy { - if m.Strategy > MergeNormal { - m.Strategy-- - } - return m, nil - } - - case "down", "right": - if m.State == PRCreateSelectStrategy { - if m.Strategy < MergeRebase { - m.Strategy++ - } - return m, nil - } } } - // Delegate to active input - var cmd tea.Cmd - switch m.State { - case PRCreateInputTitle: - updated, titleCmd := m.Title.Update(msg) - m.Title = updated - cmd = titleCmd - case PRCreateInputBody: - updated, bodyCmd := m.Body.Update(msg) - m.Body = updated - cmd = bodyCmd + if m.form == nil { + return m, nil + } + f, cmd := m.form.Update(msg) + if nf, ok := f.(*huh.Form); ok { + m.form = nf + } + if m.form.State == huh.StateCompleted { + m.syncStrategy(m.form.GetString("strategy")) + m.State = PRCreateConfirming + return m, nil + } + if m.form.State == huh.StateAborted { + m.Close() + return m, nil } - return m, cmd } // GetRequest returns the PR request for the API call. func (m *PRCreateModel) GetRequest() github.PRRequest { return github.PRRequest{ - Title: strings.TrimSpace(m.Title.Value()), - Body: strings.TrimSpace(m.Body.Value()), + Title: strings.TrimSpace(m.title), + Body: strings.TrimSpace(m.body), Head: m.HeadBranch, Base: m.BaseBranch, Draft: false, @@ -246,98 +245,61 @@ func (m *PRCreateModel) View(width int) string { return "" } - return styles.DialogBoxStyle.Render(m.renderContent(width)) -} - -func (m *PRCreateModel) renderContent(width int) string { var b strings.Builder - b.WriteString(styles.DialogTitleStyle.Render(" Create Pull Request")) b.WriteString("\n\n") - switch m.State { - case PRCreateInputTitle: - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(fmt.Sprintf(" Branch: %s → %s", m.HeadBranch, m.BaseBranch))) - b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Title (required):")) - b.WriteString("\n") - b.WriteString(m.Title.View()) - b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter to continue, Esc to cancel")) + case PRCreateForm: + if m.form != nil { + b.WriteString(m.form.View()) + } + case PRCreateConfirming: + b.WriteString(m.renderConfirm()) + case PRCreateResult: + b.WriteString(m.renderResult()) + } + return styles.DialogBoxStyle.Render(b.String()) +} - case PRCreateInputBody: - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(fmt.Sprintf(" Branch: %s → %s", m.HeadBranch, m.BaseBranch))) - b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Title:")) +// renderConfirm shows the final review before creating. +func (m *PRCreateModel) renderConfirm() string { + var b strings.Builder + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(fmt.Sprintf(" Branch: %s → %s", m.HeadBranch, m.BaseBranch))) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Title:")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + m.title)) + b.WriteString("\n\n") + if body := strings.TrimSpace(m.body); body != "" { + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Description:")) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + m.Title.Value())) + b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + body)) b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Description (optional):")) - b.WriteString("\n") - b.WriteString(m.Body.View()) - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Ctrl+D to continue, Esc to cancel")) + } + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Merge Strategy: ")) + b.WriteString(styles.ListItemActiveStyle.Render(m.Strategy.String())) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter to create PR, Esc to cancel")) + return b.String() +} - case PRCreateSelectStrategy: - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Title:")) - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + m.Title.Value())) - b.WriteString("\n\n") - if body := strings.TrimSpace(m.Body.Value()); body != "" { - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Description:")) +// renderResult shows the PR creation outcome. +func (m *PRCreateModel) renderResult() string { + var b strings.Builder + if m.Result.Success { + b.WriteString(styles.StatusStagedStyle.Render(fmt.Sprintf(" ✓ PR #%d created", m.Result.Number))) + if m.Result.URL != "" { b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + body)) - b.WriteString("\n\n") - } - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Merge Strategy:")) - b.WriteString("\n") - for _, s := range []MergeStrategy{MergeNormal, MergeSquash, MergeRebase} { - mark := " " - style := lipgloss.NewStyle().Foreground(styles.Subtext) - if s == m.Strategy { - mark = "❯ " - style = lipgloss.NewStyle().Foreground(styles.Blue).Bold(true) - } - b.WriteString(fmt.Sprintf(" %s%s\n", mark, style.Render(s.String()))) + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" " + m.Result.URL)) } - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" ↑↓ to select, Enter to confirm, Esc to cancel")) - - case PRCreateConfirming: - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(fmt.Sprintf(" Branch: %s → %s", m.HeadBranch, m.BaseBranch))) - b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Title:")) - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + m.Title.Value())) - b.WriteString("\n\n") - if body := strings.TrimSpace(m.Body.Value()); body != "" { - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Description:")) + } else { + b.WriteString(styles.ErrorStyle.Render(" ✗ PR creation failed")) + if m.Result.Error != "" { b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Text).Render(" " + body)) - b.WriteString("\n\n") - } - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" Merge Strategy: ")) - b.WriteString(styles.ListItemActiveStyle.Render(m.Strategy.String())) - b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter to create PR, Esc to cancel")) - - case PRCreateResult: - if m.Result.Success { - b.WriteString(styles.StatusStagedStyle.Render(fmt.Sprintf(" ✓ PR #%d created", m.Result.Number))) - if m.Result.URL != "" { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Render(" " + m.Result.URL)) - } - } else { - b.WriteString(styles.ErrorStyle.Render(" ✗ PR creation failed")) - if m.Result.Error != "" { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Red).Render(" " + m.Result.Error)) - } + b.WriteString(lipgloss.NewStyle().Foreground(styles.Red).Render(" " + m.Result.Error)) } - b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter or Esc to close")) } - + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(styles.Subtext).Italic(true).Render(" Press Enter or Esc to close")) return b.String() }