Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/skills/agentic-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
44 changes: 44 additions & 0 deletions docs/adr/43587-centralise-huh-form-wrappers-in-console.md
Original file line number Diff line number Diff line change
@@ -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.*
29 changes: 13 additions & 16 deletions pkg/cli/add_interactive_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 8 additions & 13 deletions pkg/cli/add_interactive_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 12 additions & 17 deletions pkg/cli/add_interactive_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
19 changes: 8 additions & 11 deletions pkg/cli/add_interactive_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down
17 changes: 7 additions & 10 deletions pkg/cli/add_interactive_schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 8 additions & 11 deletions pkg/cli/add_interactive_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down
85 changes: 39 additions & 46 deletions pkg/cli/engine_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading