diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index a7cbb15f3af..dc68e06a275 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -23,6 +23,7 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/charts.md` - `.github/aw/cli-commands.md` - `.github/aw/context.md` +- `.github/aw/create-agentic-workflow-trigger-details.md` - `.github/aw/create-agentic-workflow.md` - `.github/aw/create-shared-agentic-workflow.md` - `.github/aw/debug-agentic-workflow.md` diff --git a/docs/adr/43587-centralise-huh-form-wrappers-in-console.md b/docs/adr/43587-centralise-huh-form-wrappers-in-console.md new file mode 100644 index 00000000000..05c37000043 --- /dev/null +++ b/docs/adr/43587-centralise-huh-form-wrappers-in-console.md @@ -0,0 +1,44 @@ +# ADR-43587: Centralise `huh` Form Construction Wrappers in `pkg/console` + +**Date**: 2026-07-05 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +The `huh` TUI form library requires callers to chain `.WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode())` onto every `huh.NewForm(...)` call to apply the project's visual theme and respect the user's accessibility preferences. Before this change, this two-call chain was duplicated across at least a dozen callsites in `pkg/cli` (e.g., `add_interactive_auth.go`, `add_interactive_engine.go`, `engine_secrets.go`, `run_interactive.go`, `interactive.go`, and others). Any callsite that omitted the chain would silently render a form with incorrect styling or ignore the user's accessibility setting, creating an inconsistency that was invisible at compile time and hard to catch in review. + +### Decision + +We will introduce a small set of constructor wrappers in `pkg/console` (`NewForm`, `NewInputForm`, `NewSelectForm`, `NewMultiSelectForm`, `NewTextForm`, `NewConfirmForm`) that bake in `.WithTheme(styles.HuhTheme).WithAccessible(IsAccessibleMode())`. All interactive form callsites in `pkg/cli` and within `pkg/console` itself will migrate to these wrappers. The wrappers are gated to non-WASM builds with a `//go:build !js && !wasm` tag, preserving the existing WASM exclusion already present in the package. + +### Alternatives Considered + +#### Alternative 1: Keep per-callsite chaining (status quo) + +Each callsite continues to call `huh.NewForm(...).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode())` directly. No new abstractions are added. This is trivially easy to understand but scales poorly: the theme/accessibility coupling must be remembered and applied by every future contributor adding a new form, with no compiler enforcement. A single missed chain silently degrades the UX. + +#### Alternative 2: Configure a global default theme on `huh` at program startup + +If the `huh` library supports setting a process-wide default theme, it could be set once in `main` and omitted at callsites. This would be even less code at callsites. However, at the time of this change, the `huh` library (`charm.land/huh/v2`) does not expose a global theme default — theme must be set per-form. A future library upgrade that adds this capability would allow this approach and could supersede this ADR. + +### Consequences + +#### Positive +- Theme and accessibility mode are applied consistently to all forms; a new callsite that uses a wrapper cannot forget to apply them. +- Callsite code is simpler and shorter, reducing the boilerplate that developers must write and review. +- The single theme/accessibility configuration point means future changes to defaults (e.g., switching themes, changing accessibility detection) require editing one place instead of N callsites. + +#### Negative +- The wrappers add one extra layer of indirection between callsites and the `huh` API, which a reader must look up to understand what configuration is applied. +- The `//go:build !js && !wasm` build tag on `prompt_form.go` means WASM/JS builds still lack these wrappers; any WASM callsite that needs interactive forms requires a separate, manually maintained path. + +#### Neutral +- Existing higher-level helpers (`ConfirmAction`, `PromptSecretInput`, `ShowInteractiveList`) in `pkg/console` are also migrated to use the new wrappers, eliminating the last direct `huh.NewForm` calls inside the package itself. +- The change is purely structural: no prompt behaviour, field definitions, or observable UX outcomes change. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/add_interactive_auth.go b/pkg/cli/add_interactive_auth.go index 6bcaa2b0e04..6083d844b32 100644 --- a/pkg/cli/add_interactive_auth.go +++ b/pkg/cli/add_interactive_auth.go @@ -8,7 +8,6 @@ import ( "charm.land/huh/v2" "github.com/github/gh-aw/pkg/console" - "github.com/github/gh-aw/pkg/styles" ) // checkGHAuthStatus verifies the user is logged in to GitHub CLI @@ -42,21 +41,19 @@ func (c *AddInteractiveConfig) checkGitRepository() error { fmt.Fprintln(os.Stderr, "") var userRepo string - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Enter the target repository (owner/repo):"). - Description("For example: myorg/myrepo"). - Value(&userRepo). - Validate(func(s string) error { - parts := strings.Split(s, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return errors.New("please enter in format 'owner/repo'") - } - return nil - }), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewInputForm( + huh.NewInput(). + Title("Enter the target repository (owner/repo):"). + Description("For example: myorg/myrepo"). + Value(&userRepo). + Validate(func(s string) error { + parts := strings.Split(s, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return errors.New("please enter in format 'owner/repo'") + } + return nil + }), + ) if err := form.RunWithContext(c.Ctx); err != nil { return fmt.Errorf("failed to get repository info: %w", err) diff --git a/pkg/cli/add_interactive_engine.go b/pkg/cli/add_interactive_engine.go index 3e9dce72b5f..28194a74cea 100644 --- a/pkg/cli/add_interactive_engine.go +++ b/pkg/cli/add_interactive_engine.go @@ -12,7 +12,6 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/setutil" "github.com/github/gh-aw/pkg/sliceutil" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/workflow" ) @@ -120,15 +119,13 @@ func (c *AddInteractiveConfig) selectAIEngineAndKey() error { } fmt.Fprintln(os.Stderr, "") - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Which coding agent would you like to use?"). - Description("This determines which coding agent processes your workflows"). - Options(engineOptions...). - Value(&selectedEngine), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewSelectForm( + huh.NewSelect[string](). + Title("Which coding agent would you like to use?"). + Description("This determines which coding agent processes your workflows"). + Options(engineOptions...). + Value(&selectedEngine), + ) if err := form.RunWithContext(c.Ctx); err != nil { return fmt.Errorf("failed to select coding agent: %w", err) @@ -277,9 +274,7 @@ func (c *AddInteractiveConfig) selectCopilotAuthMethod() error { }) } - form := huh.NewForm( - huh.NewGroup(selectField), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewSelectForm(selectField) if err := form.RunWithContext(c.Ctx); err != nil { return fmt.Errorf("failed to select Copilot authentication method: %w", err) diff --git a/pkg/cli/add_interactive_git.go b/pkg/cli/add_interactive_git.go index 90d033cdb1a..aec29994ee9 100644 --- a/pkg/cli/add_interactive_git.go +++ b/pkg/cli/add_interactive_git.go @@ -11,7 +11,6 @@ import ( "charm.land/huh/v2" "github.com/github/gh-aw/pkg/console" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/workflow" ) @@ -99,14 +98,12 @@ func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Co } var chosen mergeAction - selectForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[mergeAction](). - Title("What would you like to do with pull request " + result.PRURL + "?"). - Options(options...). - Value(&chosen), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + selectForm := console.NewSelectForm( + huh.NewSelect[mergeAction](). + Title("What would you like to do with pull request " + result.PRURL + "?"). + Options(options...). + Value(&chosen), + ) if selectErr := selectForm.Run(); selectErr != nil { return fmt.Errorf("failed to get user input: %w", selectErr) @@ -132,14 +129,12 @@ func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Co case mergeActionEditTitle: var newTitle string - titleForm := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Enter new PR title"). - Description("Add a prefix if required, for example: feat: or fix:"). - Value(&newTitle), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + titleForm := console.NewInputForm( + huh.NewInput(). + Title("Enter new PR title"). + Description("Add a prefix if required, for example: feat: or fix:"). + Value(&newTitle), + ) if titleErr := titleForm.Run(); titleErr != nil { return fmt.Errorf("failed to get user input: %w", titleErr) } diff --git a/pkg/cli/add_interactive_orchestrator.go b/pkg/cli/add_interactive_orchestrator.go index 241fbe5a530..1ab875d28a7 100644 --- a/pkg/cli/add_interactive_orchestrator.go +++ b/pkg/cli/add_interactive_orchestrator.go @@ -11,7 +11,6 @@ import ( "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/workflow" ) @@ -297,16 +296,14 @@ func (c *AddInteractiveConfig) confirmChanges(workflowFiles, initFiles []string, fmt.Fprintln(os.Stderr, "") confirmed := true // Default to yes - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Do you want to proceed with these changes?"). - Description("A pull request will be created with the workflow files"). - Affirmative("Yes, create pull request"). - Negative("No, cancel"). - Value(&confirmed), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewConfirmForm( + huh.NewConfirm(). + Title("Do you want to proceed with these changes?"). + Description("A pull request will be created with the workflow files"). + Affirmative("Yes, create pull request"). + Negative("No, cancel"). + Value(&confirmed), + ) if err := form.RunWithContext(c.Ctx); err != nil { return fmt.Errorf("confirmation failed: %w", err) diff --git a/pkg/cli/add_interactive_schedule.go b/pkg/cli/add_interactive_schedule.go index 93c7cdf235e..5e3961d6625 100644 --- a/pkg/cli/add_interactive_schedule.go +++ b/pkg/cli/add_interactive_schedule.go @@ -10,7 +10,6 @@ import ( "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/parser" "github.com/github/gh-aw/pkg/sliceutil" - "github.com/github/gh-aw/pkg/styles" ) var scheduleWizardLog = logger.New("cli:add_interactive_schedule") @@ -220,15 +219,13 @@ func (c *AddInteractiveConfig) selectScheduleFrequency() error { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("This workflow runs on a schedule.")) var selected string - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("How often should this workflow run?"). - Description("Current schedule: " + rawExpr). - Options(options...). - Value(&selected), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewSelectForm( + huh.NewSelect[string](). + Title("How often should this workflow run?"). + Description("Current schedule: " + rawExpr). + Options(options...). + Value(&selected), + ) if err := form.RunWithContext(c.Ctx); err != nil { return fmt.Errorf("failed to select schedule frequency: %w", err) diff --git a/pkg/cli/add_interactive_workflow.go b/pkg/cli/add_interactive_workflow.go index 1828d5826b8..8cab14f7afd 100644 --- a/pkg/cli/add_interactive_workflow.go +++ b/pkg/cli/add_interactive_workflow.go @@ -10,7 +10,6 @@ import ( "charm.land/huh/v2" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/workflow" ) @@ -100,16 +99,14 @@ func (c *AddInteractiveConfig) checkStatusAndOfferRun(ctx context.Context) error // Ask if user wants to run the workflow fmt.Fprintln(os.Stderr, "") runNow := true // Default to yes - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Would you like to run the workflow once now?"). - Description("This will trigger the workflow immediately"). - Affirmative("Yes, run once now"). - Negative("No, I'll run later"). - Value(&runNow), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewConfirmForm( + huh.NewConfirm(). + Title("Would you like to run the workflow once now?"). + Description("This will trigger the workflow immediately"). + Affirmative("Yes, run once now"). + Negative("No, I'll run later"). + Value(&runNow), + ) if err := form.RunWithContext(ctx); err != nil { return nil // Not critical, just skip diff --git a/pkg/cli/engine_secrets.go b/pkg/cli/engine_secrets.go index f733d2cf556..a84a6569993 100644 --- a/pkg/cli/engine_secrets.go +++ b/pkg/cli/engine_secrets.go @@ -16,7 +16,6 @@ import ( "github.com/github/gh-aw/pkg/setutil" "github.com/github/gh-aw/pkg/sliceutil" "github.com/github/gh-aw/pkg/stringutil" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/workflow" ) @@ -308,21 +307,19 @@ func promptForCopilotPATUnified(req SecretRequirement, config EngineSecretConfig fmt.Fprintln(os.Stderr, "If you run into trouble see https://github.github.com/gh-aw/reference/auth/#copilot_github_token.") var token string - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("After creating, please paste your fine-grained Copilot PAT:"). - Description("Must start with 'github_pat_'. Classic PATs (ghp_...) are not supported."). - EchoMode(huh.EchoModePassword). - Value(&token). - Validate(func(s string) error { - if len(s) < 10 { - return errors.New("token appears to be too short") - } - return stringutil.ValidateCopilotPAT(s) - }), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewInputForm( + huh.NewInput(). + Title("After creating, please paste your fine-grained Copilot PAT:"). + Description("Must start with 'github_pat_'. Classic PATs (ghp_...) are not supported."). + EchoMode(huh.EchoModePassword). + Value(&token). + Validate(func(s string) error { + if len(s) < 10 { + return errors.New("token appears to be too short") + } + return stringutil.ValidateCopilotPAT(s) + }), + ) if err := form.RunWithContext(config.ctx()); err != nil { return fmt.Errorf("failed to get Copilot token: %w", err) @@ -354,21 +351,19 @@ func promptForSystemTokenUnified(req SecretRequirement, config EngineSecretConfi fmt.Fprintln(os.Stderr, "") var token string - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title(fmt.Sprintf("Paste your %s token:", req.Name)). - Description("The token will be stored securely as a repository secret"). - EchoMode(huh.EchoModePassword). - Value(&token). - Validate(func(s string) error { - if len(s) < 10 { - return errors.New("token appears to be too short") - } - return nil - }), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewInputForm( + huh.NewInput(). + Title(fmt.Sprintf("Paste your %s token:", req.Name)). + Description("The token will be stored securely as a repository secret"). + EchoMode(huh.EchoModePassword). + Value(&token). + Validate(func(s string) error { + if len(s) < 10 { + return errors.New("token appears to be too short") + } + return nil + }), + ) if err := form.RunWithContext(config.ctx()); err != nil { return fmt.Errorf("failed to get %s token: %w", req.Name, err) @@ -405,21 +400,19 @@ func promptForGenericAPIKeyUnified(req SecretRequirement, config EngineSecretCon } var apiKey string - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title(fmt.Sprintf("Paste your %s API key:", label)). - Description("The key will be stored securely as a repository secret"). - EchoMode(huh.EchoModePassword). - Value(&apiKey). - Validate(func(s string) error { - if len(s) < 10 { - return errors.New("API key appears to be too short") - } - return nil - }), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewInputForm( + huh.NewInput(). + Title(fmt.Sprintf("Paste your %s API key:", label)). + Description("The key will be stored securely as a repository secret"). + EchoMode(huh.EchoModePassword). + Value(&apiKey). + Validate(func(s string) error { + if len(s) < 10 { + return errors.New("API key appears to be too short") + } + return nil + }), + ) if err := form.RunWithContext(config.ctx()); err != nil { return fmt.Errorf("failed to get %s API key: %w", label, err) diff --git a/pkg/cli/interactive.go b/pkg/cli/interactive.go index 6ff7d569b8e..be4c1f3affb 100644 --- a/pkg/cli/interactive.go +++ b/pkg/cli/interactive.go @@ -18,7 +18,6 @@ import ( "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/setutil" "github.com/github/gh-aw/pkg/sliceutil" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/tty" "github.com/github/gh-aw/pkg/workflow" ) @@ -109,16 +108,14 @@ func (b *InteractiveWorkflowBuilder) promptForWorkflowName() error { return b.promptForWorkflowNameFrom(os.Stdin) } - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("What should we call this workflow?"). - Description("Enter a descriptive name for your workflow (e.g., 'issue-triage', 'code-review-helper')"). - Suggestions(commonWorkflowNames). - Value(&b.WorkflowName). - Validate(ValidateWorkflowName), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewInputForm( + huh.NewInput(). + Title("What should we call this workflow?"). + Description("Enter a descriptive name for your workflow (e.g., 'issue-triage', 'code-review-helper')"). + Suggestions(commonWorkflowNames). + Value(&b.WorkflowName). + Validate(ValidateWorkflowName), + ) return form.RunWithContext(b.ctx) } @@ -210,7 +207,7 @@ func (b *InteractiveWorkflowBuilder) promptForConfiguration() error { var selectedOutputs []string // Create form with organized groups - form := huh.NewForm( + form := console.NewForm( // Group 1: Basic Configuration huh.NewGroup( huh.NewSelect[string](). @@ -267,7 +264,7 @@ func (b *InteractiveWorkflowBuilder) promptForConfiguration() error { ). Title("Instructions"). Description("Describe what you want this workflow to accomplish"), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + ) if err := form.RunWithContext(b.ctx); err != nil { return err @@ -498,15 +495,13 @@ func (b *InteractiveWorkflowBuilder) generateWorkflow(force bool) error { // Check if destination file already exists if _, err := os.Stat(destFile); err == nil && !force { var overwrite bool - confirmForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title(fmt.Sprintf("Workflow file '%s' already exists. Overwrite?", filepath.Base(destFile))). - Affirmative("Yes, overwrite"). - Negative("No, cancel"). - Value(&overwrite), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + confirmForm := console.NewConfirmForm( + huh.NewConfirm(). + Title(fmt.Sprintf("Workflow file '%s' already exists. Overwrite?", filepath.Base(destFile))). + Affirmative("Yes, overwrite"). + Negative("No, cancel"). + Value(&overwrite), + ) if err := confirmForm.Run(); err != nil { return fmt.Errorf("confirmation failed: %w", err) diff --git a/pkg/cli/run_interactive.go b/pkg/cli/run_interactive.go index e1463f3f28b..891260093e6 100644 --- a/pkg/cli/run_interactive.go +++ b/pkg/cli/run_interactive.go @@ -13,7 +13,6 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/sliceutil" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/tty" "github.com/github/gh-aw/pkg/workflow" ) @@ -180,17 +179,15 @@ func selectWorkflow(ctx context.Context, workflows []WorkflowOption) (*WorkflowO options := sliceutil.Map(workflows, func(wf WorkflowOption) huh.Option[string] { return huh.NewOption(wf.Name, wf.Name) }) var selected string - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Select a workflow to run"). - Description("↑/↓ to navigate, / to search, Enter to select"). - Options(options...). - Filtering(true). - Height(15). - Value(&selected), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewSelectForm( + huh.NewSelect[string](). + Title("Select a workflow to run"). + Description("↑/↓ to navigate, / to search, Enter to select"). + Options(options...). + Filtering(true). + Height(15). + Value(&selected), + ) if err := form.RunWithContext(ctx); err != nil { return nil, fmt.Errorf("workflow selection cancelled or failed: %w", err) @@ -314,7 +311,7 @@ func collectInputsWithMap(ctx context.Context, inputs map[string]*workflow.Input } // Show the form - form := huh.NewForm(formGroups...).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewForm(formGroups...) if err := form.RunWithContext(ctx); err != nil { return nil, fmt.Errorf("input collection cancelled: %w", err) } @@ -343,15 +340,13 @@ func confirmExecution(ctx context.Context, wf *WorkflowOption, inputs []string) message = fmt.Sprintf("Run workflow '%s' with %d input(s)?", wf.Name, len(inputs)) } - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title(message). - Affirmative("Yes, run it"). - Negative("No, cancel"). - Value(&confirm), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(console.IsAccessibleMode()) + form := console.NewConfirmForm( + huh.NewConfirm(). + Title(message). + Affirmative("Yes, run it"). + Negative("No, cancel"). + Value(&confirm), + ) if err := form.RunWithContext(ctx); err != nil { runInteractiveLog.Printf("Confirmation failed: %v", err) diff --git a/pkg/console/confirm.go b/pkg/console/confirm.go index ccb25ec37b6..9cd1ab63b6e 100644 --- a/pkg/console/confirm.go +++ b/pkg/console/confirm.go @@ -10,7 +10,6 @@ import ( "charm.land/huh/v2" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/tty" ) @@ -29,15 +28,13 @@ func ConfirmAction(title, affirmative, negative string) (bool, error) { var confirmed bool - confirmForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title(title). - Affirmative(affirmative). - Negative(negative). - Value(&confirmed), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(IsAccessibleMode()) + confirmForm := NewConfirmForm( + huh.NewConfirm(). + Title(title). + Affirmative(affirmative). + Negative(negative). + Value(&confirmed), + ) if err := confirmForm.Run(); err != nil { confirmLog.Printf("Error running confirm form: %v", err) diff --git a/pkg/console/input.go b/pkg/console/input.go index bf5727b94f5..76b46b32534 100644 --- a/pkg/console/input.go +++ b/pkg/console/input.go @@ -6,7 +6,6 @@ import ( "errors" "charm.land/huh/v2" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/tty" ) @@ -21,21 +20,19 @@ func PromptSecretInput(title, description string) (string, error) { var value string - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title(title). - Description(description). - EchoMode(huh.EchoModePassword). // Masks input for security - Validate(func(s string) error { - if s == "" { - return errors.New("value cannot be empty") - } - return nil - }). - Value(&value), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(IsAccessibleMode()) + form := NewInputForm( + huh.NewInput(). + Title(title). + Description(description). + EchoMode(huh.EchoModePassword). // Masks input for security + Validate(func(s string) error { + if s == "" { + return errors.New("value cannot be empty") + } + return nil + }). + Value(&value), + ) if err := form.Run(); err != nil { return "", err diff --git a/pkg/console/list.go b/pkg/console/list.go index 5b0b383c225..751e4ec17de 100644 --- a/pkg/console/list.go +++ b/pkg/console/list.go @@ -8,7 +8,6 @@ import ( "charm.land/huh/v2" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/tty" ) @@ -43,14 +42,12 @@ func ShowInteractiveList(title string, items []ListItem) (string, error) { } var selected string - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title(title). - Options(opts...). - Value(&selected), - ), - ).WithTheme(styles.HuhTheme).WithAccessible(IsAccessibleMode()) + form := NewSelectForm( + huh.NewSelect[string](). + Title(title). + Options(opts...). + Value(&selected), + ) if err := form.Run(); err != nil { listLog.Printf("Error running list form: %v", err) diff --git a/pkg/console/prompt_form.go b/pkg/console/prompt_form.go new file mode 100644 index 00000000000..70b3d529910 --- /dev/null +++ b/pkg/console/prompt_form.go @@ -0,0 +1,38 @@ +//go:build !js && !wasm + +package console + +import ( + "charm.land/huh/v2" + "github.com/github/gh-aw/pkg/styles" +) + +// NewForm creates a huh form with gh-aw's default theme and accessibility mode. +func NewForm(groups ...*huh.Group) *huh.Form { + return huh.NewForm(groups...).WithTheme(styles.HuhTheme).WithAccessible(IsAccessibleMode()) +} + +// NewInputForm creates a themed, accessibility-aware single-input form. +func NewInputForm(input *huh.Input) *huh.Form { + return NewForm(huh.NewGroup(input)) +} + +// NewSelectForm creates a themed, accessibility-aware single-select form. +func NewSelectForm[T comparable](selectField *huh.Select[T]) *huh.Form { + return NewForm(huh.NewGroup(selectField)) +} + +// NewMultiSelectForm creates a themed, accessibility-aware single multi-select form. +func NewMultiSelectForm[T comparable](multiSelect *huh.MultiSelect[T]) *huh.Form { + return NewForm(huh.NewGroup(multiSelect)) +} + +// NewTextForm creates a themed, accessibility-aware single-text form. +func NewTextForm(text *huh.Text) *huh.Form { + return NewForm(huh.NewGroup(text)) +} + +// NewConfirmForm creates a themed, accessibility-aware single-confirm form. +func NewConfirmForm(confirm *huh.Confirm) *huh.Form { + return NewForm(huh.NewGroup(confirm)) +} diff --git a/pkg/console/prompt_form_test.go b/pkg/console/prompt_form_test.go new file mode 100644 index 00000000000..bb0cf636e56 --- /dev/null +++ b/pkg/console/prompt_form_test.go @@ -0,0 +1,31 @@ +//go:build !integration && !js && !wasm + +package console + +import ( + "testing" + + "charm.land/huh/v2" + "github.com/stretchr/testify/require" +) + +func TestPromptWrappersReturnNonNilForms(t *testing.T) { + var inputValue string + require.NotNil(t, NewInputForm(huh.NewInput().Value(&inputValue))) + + var selectValue string + require.NotNil(t, NewSelectForm(huh.NewSelect[string](). + Options(huh.NewOption("Option", "option")). + Value(&selectValue))) + + var multiValue []string + require.NotNil(t, NewMultiSelectForm(huh.NewMultiSelect[string](). + Options(huh.NewOption("Option", "option")). + Value(&multiValue))) + + var textValue string + require.NotNil(t, NewTextForm(huh.NewText().Value(&textValue))) + + var confirmValue bool + require.NotNil(t, NewConfirmForm(huh.NewConfirm().Value(&confirmValue))) +}