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
58 changes: 50 additions & 8 deletions pkg/cli/run_interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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/tty"
"github.com/github/gh-aw/pkg/workflow"
)

Expand Down Expand Up @@ -153,20 +154,36 @@ func buildWorkflowDescription(inputs map[string]*workflow.InputDefinition) strin
return ""
}

// selectWorkflow displays an interactive list for workflow selection
// selectWorkflow displays an interactive list for workflow selection with fuzzy search
func selectWorkflow(workflows []WorkflowOption) (*WorkflowOption, error) {
runInteractiveLog.Printf("Displaying workflow selection: %d workflows", len(workflows))

// Build list items
items := make([]console.ListItem, len(workflows))
// Check if we're in a TTY environment
if !tty.IsStderrTerminal() {
return selectWorkflowNonInteractive(workflows)
}

// Build select options
options := make([]huh.Option[string], len(workflows))
for i, wf := range workflows {
items[i] = console.NewListItem(wf.Name, wf.Description, wf.Name)
options[i] = huh.NewOption(wf.Name, wf.Name)
}

// Show interactive list
selected, err := console.ShowInteractiveList("Select a workflow to run:", items)
if err != nil {
return nil, err
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),
),
).WithAccessible(console.IsAccessibleMode())

if err := form.Run(); err != nil {
return nil, fmt.Errorf("workflow selection cancelled or failed: %w", err)
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectWorkflow wraps form.Run() errors with "workflow selection cancelled or failed" but the caller (RunWorkflowInteractively) already wraps errors with the same prefix. This will produce duplicated messages like "workflow selection cancelled or failed: workflow selection cancelled or failed: ..."; prefer wrapping at only one layer (either here or at the call site) with a more specific message at the other layer.

Suggested change
return nil, fmt.Errorf("workflow selection cancelled or failed: %w", err)
return nil, err

Copilot uses AI. Check for mistakes.
}

// Find the selected workflow
Expand All @@ -179,6 +196,31 @@ func selectWorkflow(workflows []WorkflowOption) (*WorkflowOption, error) {
return nil, fmt.Errorf("selected workflow not found: %s", selected)
}

// selectWorkflowNonInteractive provides a fallback for non-TTY environments
func selectWorkflowNonInteractive(workflows []WorkflowOption) (*WorkflowOption, error) {
runInteractiveLog.Printf("Non-TTY detected, showing text list: %d workflows", len(workflows))

fmt.Fprintf(os.Stderr, "\nSelect a workflow to run:\n\n")
for i, wf := range workflows {
fmt.Fprintf(os.Stderr, " %d) %s\n", i+1, wf.Name)
}
fmt.Fprintf(os.Stderr, "\nSelect (1-%d): ", len(workflows))

var choice int
_, err := fmt.Scanf("%d", &choice)
if err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}

if choice < 1 || choice > len(workflows) {
return nil, fmt.Errorf("selection out of range (must be 1-%d)", len(workflows))
}

selectedWorkflow := &workflows[choice-1]
runInteractiveLog.Printf("Selected workflow from text list: %s", selectedWorkflow.Name)
return selectedWorkflow, nil
Comment on lines +199 to +221
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectWorkflowNonInteractive largely duplicates the existing numbered-selection fallback in console.ShowInteractiveList (showTextList in pkg/console/list.go). Consider extracting a shared exported helper (e.g., console.ShowTextList) or reusing existing console abstractions to avoid having two nearly-identical implementations to maintain and keep behavior consistent (formatting, error messages, handling descriptions).

Copilot uses AI. Check for mistakes.
}

// showWorkflowInfo displays information about the selected workflow
func showWorkflowInfo(wf *WorkflowOption) {
fmt.Fprintln(os.Stderr, "")
Expand Down
105 changes: 105 additions & 0 deletions pkg/cli/run_interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,108 @@ jobs:
// Verify description is empty (input counts no longer shown)
assert.Empty(t, wf.Description)
}

// TestSelectWorkflowStructure tests that selectWorkflow creates the correct Huh form structure
func TestSelectWorkflowStructure(t *testing.T) {
// This test verifies that the selectWorkflow function would create a properly
// configured huh.Select with fuzzy filtering enabled

workflows := []WorkflowOption{
{Name: "workflow-a", Description: "", FilePath: "workflow-a.md"},
{Name: "workflow-b", Description: "", FilePath: "workflow-b.md"},
{Name: "test-workflow", Description: "", FilePath: "test-workflow.md"},
}

// Verify we have the expected number of workflows
assert.Len(t, workflows, 3)

// Verify workflow names for fuzzy matching
workflowNames := make([]string, len(workflows))
for i, wf := range workflows {
workflowNames[i] = wf.Name
}

assert.Contains(t, workflowNames, "workflow-a")
assert.Contains(t, workflowNames, "workflow-b")
assert.Contains(t, workflowNames, "test-workflow")
}
Comment on lines +372 to +395
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestSelectWorkflowStructure doesn't exercise selectWorkflow or verify any of the Huh configuration (Filtering(true), Height(15), description text, accessible mode, etc.). As written it only asserts properties of a locally-constructed slice and would continue to pass even if selectWorkflow were removed or misconfigured; consider refactoring selectWorkflow to expose a testable helper (e.g., build options/config) or adding an integration-style test with a controllable form runner/TTY check.

This issue also appears in the following locations of the same file:

  • line 397
  • line 460

Copilot uses AI. Check for mistakes.

// TestSelectWorkflowFuzzySearchability tests that workflow names are searchable
func TestSelectWorkflowFuzzySearchability(t *testing.T) {
// Test that workflow names can be matched by fuzzy search patterns
tests := []struct {
name string
workflowName string
searchPattern string
shouldMatch bool
}{
{
name: "exact match",
workflowName: "test-workflow",
searchPattern: "test-workflow",
shouldMatch: true,
},
{
name: "partial match",
workflowName: "test-workflow",
searchPattern: "test",
shouldMatch: true,
},
{
name: "fuzzy match",
workflowName: "test-workflow",
searchPattern: "twf",
shouldMatch: true, // t(est-) w(ork) f(low)
},
{
name: "case insensitive",
workflowName: "test-workflow",
searchPattern: "TEST",
shouldMatch: true,
},
{
name: "no match",
workflowName: "test-workflow",
searchPattern: "xyz",
shouldMatch: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simple substring matching for testing (Huh's fuzzy matching is more sophisticated)
matched := strings.Contains(strings.ToLower(tt.workflowName), strings.ToLower(tt.searchPattern))

if tt.shouldMatch {
// For fuzzy patterns like "twf", we just verify the workflow name contains the characters
if tt.searchPattern == "twf" {
// Check that workflow name contains 't', 'w', and 'f' in order
assert.Contains(t, tt.workflowName, "t")
assert.Contains(t, tt.workflowName, "w")
assert.Contains(t, tt.workflowName, "f")
} else {
assert.True(t, matched, "Expected workflow %q to match pattern %q", tt.workflowName, tt.searchPattern)
}
} else {
assert.False(t, matched, "Expected workflow %q not to match pattern %q", tt.workflowName, tt.searchPattern)
}
})
}
}

// TestSelectWorkflowNonInteractive tests the non-interactive fallback
func TestSelectWorkflowNonInteractive(t *testing.T) {
workflows := []WorkflowOption{
{Name: "workflow-a", Description: "", FilePath: "workflow-a.md"},
{Name: "workflow-b", Description: "", FilePath: "workflow-b.md"},
{Name: "test-workflow", Description: "", FilePath: "test-workflow.md"},
}

// Test that selectWorkflowNonInteractive would format workflows correctly
assert.Len(t, workflows, 3)

// Verify each workflow has a name for selection
for i, wf := range workflows {
assert.NotEmpty(t, wf.Name, "Workflow at index %d should have a name", i)
}
}
8 changes: 4 additions & 4 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
var toolsBuilder strings.Builder
var mcpServersBuilder strings.Builder
var markdownBuilder strings.Builder // Only used for imports WITH inputs (compile-time substitution)
var importPaths []string // NEW: Track import paths for runtime-import macro generation
var importPaths []string // NEW: Track import paths for runtime-import macro generation
var stepsBuilder strings.Builder
var copilotSetupStepsBuilder strings.Builder // Track copilot-setup-steps.yml separately
var runtimesBuilder strings.Builder
Expand Down Expand Up @@ -317,7 +317,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
} else {
// Has inputs - must inline for compile-time substitution
log.Printf("Agent file has inputs - will be inlined instead of runtime-imported")

// For agent files, extract markdown content (only when inputs are present)
markdownContent, err := processIncludedFileWithVisited(item.fullPath, item.sectionName, false, visited)
if err != nil {
Expand Down Expand Up @@ -489,7 +489,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
} else {
// Has inputs - must inline for compile-time substitution
log.Printf("Import %s has inputs - will be inlined for compile-time substitution", importRelPath)

// Extract markdown content from imported file (only for imports with inputs)
markdownContent, err := processIncludedFileWithVisited(item.fullPath, item.sectionName, false, visited)
if err != nil {
Expand Down Expand Up @@ -639,7 +639,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
MergedSafeOutputs: safeOutputs,
MergedSafeInputs: safeInputs,
MergedMarkdown: markdownBuilder.String(), // Only imports WITH inputs (for compile-time substitution)
ImportPaths: importPaths, // Import paths for runtime-import macro generation
ImportPaths: importPaths, // Import paths for runtime-import macro generation
MergedSteps: stepsBuilder.String(),
CopilotSetupSteps: copilotSetupStepsBuilder.String(),
MergedRuntimes: runtimesBuilder.String(),
Expand Down
Loading