Skip to content
Open
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
136 changes: 136 additions & 0 deletions cmd/substreams/enhanced_input.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package main

import (
"fmt"
"regexp"
"strings"

"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

// EnhancedInput is a custom input component with history and tab completion
type EnhancedInput struct {
textInput textinput.Model
history *InputHistory
prompt string
description string
validation *regexp.Regexp
validationErr string
submitted bool
cancelled bool
}

// NewEnhancedInput creates a new enhanced input component
func NewEnhancedInput(prompt, description, placeholder, defaultValue string, validationRegex, validationError string) *EnhancedInput {
ti := textinput.New()
ti.Placeholder = placeholder
ti.SetValue(defaultValue)
ti.Focus()

var validationRE *regexp.Regexp
if validationRegex != "" {
var err error
validationRE, err = regexp.Compile(validationRegex)
if err != nil {
// Log error but continue without validation
validationRE = nil
}
}

return &EnhancedInput{
textInput: ti,
history: globalInputHistory,
prompt: prompt,
description: description,
validation: validationRE,
validationErr: validationError,
}
}

// Init implements tea.Model
func (e *EnhancedInput) Init() tea.Cmd {
return textinput.Blink
}

// Update implements tea.Model
func (e *EnhancedInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd

switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
e.cancelled = true
return e, tea.Quit

case "enter":
value := strings.TrimRight(e.textInput.Value(), " ")

// Validate if validation is set
if e.validation != nil && !e.validation.MatchString(value) {
// Show validation error (could use a status message)
return e, nil
}

e.submitted = true
e.history.Add(value)
e.history.Reset()
return e, tea.Quit

case "up":
if value, changed := e.history.NavigateUp(e.textInput.Value()); changed {
e.textInput.SetValue(value)
// Move cursor to end
e.textInput.CursorEnd()
}
return e, nil

case "down":
if value, changed := e.history.NavigateDown(e.textInput.Value()); changed {
e.textInput.SetValue(value)
e.textInput.CursorEnd()
}
return e, nil

// Note: Tab completion removed - use FilePicker for file paths instead
}
}

e.textInput, cmd = e.textInput.Update(msg)
return e, cmd
}

// View implements tea.Model
func (e *EnhancedInput) View() string {
titleStyle := lipgloss.NewStyle().Bold(true)
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))

view := ""
if e.prompt != "" {
view += titleStyle.Render(e.prompt) + "\n"
}
if e.description != "" {
view += descStyle.Render(e.description) + "\n"
}
view += e.textInput.View() + "\n"

return view
}

// Run executes the enhanced input and returns the value and any error
func (e *EnhancedInput) Run() (string, error) {
p := tea.NewProgram(e)
finalModel, err := p.Run()
if err != nil {
return "", err
}

finalInput := finalModel.(*EnhancedInput)
if finalInput.cancelled {
return "", fmt.Errorf("input cancelled")
}

return finalInput.textInput.Value(), nil
}
131 changes: 109 additions & 22 deletions cmd/substreams/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ func init() {
var INIT_TRACE = false
var WITH_ACCESSIBLE = false

var globalInputHistory *InputHistory

func init() {
// Initialize input history tracker
globalInputHistory = NewInputHistory()
}

type initStateFormat struct {
GeneratorID string `json:"generator"`
State json.RawMessage `json:"state"`
Expand All @@ -98,6 +105,83 @@ func newUserState() *UserState {
return &UserState{}
}

// isFilePathInput uses heuristics to determine if an input is requesting a file path
func isFilePathInput(prompt, description, placeholder string) bool {
combined := strings.ToLower(prompt + " " + description + " " + placeholder)

// File extensions (strong indicators)
fileExtensions := []string{".json", ".sol", ".yaml", ".yml", ".abi", ".txt", ".csv", ".md"}
for _, ext := range fileExtensions {
if strings.Contains(combined, ext) {
return true
}
}

// Word-boundary file keywords (avoid matching within words like "profile")
words := strings.Fields(combined)
fileKeywords := []string{"file", "path", "directory", "folder", "filepath", "dirpath"}
for _, word := range words {
for _, keyword := range fileKeywords {
if word == keyword {
return true
}
}
}

// Multi-word file indicators
contractFileKeywords := []string{"contract file", "abi file", "config file", "source file"}
for _, keyword := range contractFileKeywords {
if strings.Contains(combined, keyword) {
return true
}
}

return false
}

// runFilePicker runs the huh FilePicker component
func runFilePicker(prompt, description, defaultPath string) (string, error) {
// Determine starting directory
startDir := "."
if defaultPath != "" {
if stat, err := os.Stat(defaultPath); err == nil && stat.IsDir() {
startDir = defaultPath
} else {
startDir = filepath.Dir(defaultPath)
}
}

var selectedPath string

// Create FilePicker using huh API
picker := huh.NewFilePicker().
Title(prompt).
Description(description).
CurrentDirectory(startDir).
Value(&selectedPath)

err := huh.NewForm(huh.NewGroup(picker)).WithTheme(huhTheme).WithAccessible(WITH_ACCESSIBLE).Run()
if err != nil {
return "", err
}

return selectedPath, nil
}

// runEnhancedTextInput runs enhanced text input with history
func runEnhancedTextInput(prompt, description, placeholder, defaultValue, validationRegex, validationError string) (string, error) {
enhancedInput := NewEnhancedInput(
prompt,
description,
placeholder,
defaultValue,
validationRegex,
validationError,
)

return enhancedInput.Run()
}

func readGeneratorState(stateFile string) (*initStateFormat, error) {
stateBytes, err := os.ReadFile(stateFile)
if err != nil {
Expand Down Expand Up @@ -434,37 +518,40 @@ func runSubstreamsInitE(cmd *cobra.Command, args []string) error {
case *pbconvo.SystemOutput_TextInput_:
input := msg.TextInput

returnValue := input.DefaultValue
// Check if this input is requesting a file path
isFilePath := isFilePathInput(input.Prompt, input.Description, input.Placeholder)

inputField := huh.NewInput().
Title(input.Prompt).
Description(input.Description).
Placeholder(input.Placeholder).
Value(&returnValue)
var returnValue string
var err error

if input.ValidationRegexp != "" {
validationRE, err := regexp.Compile(input.ValidationRegexp)
if isFilePath {
// Use FilePicker for file path inputs
returnValue, err = runFilePicker(input.Prompt, input.Description, input.DefaultValue)
if err != nil {
return fmt.Errorf("invalid regexp received from server (%q) to validate text input: %w", msg.TextInput.ValidationRegexp, err)
return fmt.Errorf("failed taking file input: %w", err)
}
} else {
// Use enhanced text input with history for other inputs
returnValue, err = runEnhancedTextInput(
input.Prompt,
input.Description,
input.Placeholder,
input.DefaultValue,
input.ValidationRegexp,
input.ValidationErrorMessage,
)
if err != nil {
return fmt.Errorf("failed taking text input: %w", err)
}

inputField.Validate(func(userInput string) error {
matched := validationRE.MatchString(strings.TrimRight(returnValue, " "))
if !matched {
return errors.New(input.ValidationErrorMessage)
}
return nil
})
}

err := huh.NewForm(huh.NewGroup(inputField)).WithTheme(huhTheme).WithAccessible(WITH_ACCESSIBLE).Run()
if err != nil {
return fmt.Errorf("failed taking input: %w", err)
}

fmt.Println(gray("┃"), input.Prompt+":", bold(returnValue))
fmt.Println("")

// Record in history
globalInputHistory.Add(returnValue)

// Send to server
if err := sendFunc(&pbconvo.UserInput{
FromActionId: resp.ActionId,
Entry: &pbconvo.UserInput_TextInput_{
Expand Down
Loading
Loading