diff --git a/pkg/cli/repl/completer.go b/pkg/cli/repl/completer.go new file mode 100644 index 0000000..9cb119a --- /dev/null +++ b/pkg/cli/repl/completer.go @@ -0,0 +1,31 @@ +package repl + +import ( + "strings" + + "github.com/c-bata/go-prompt" +) + +func completer(d prompt.Document) []prompt.Suggest { + text := d.TextBeforeCursor() + words := strings.Fields(text) + + if len(words) == 0 { + return nil + } + + if words[0] != "pm" { + return filterByPrefix(rootSuggestions, words[0]) + } + + pmWords := words[1:] + if len(words) == 1 || (len(pmWords) == 1 && !strings.HasSuffix(text, " ")) { + return commandSuggestions(pmWords) + } + + if len(pmWords) >= 1 { + return flagSuggestions(pmWords[0], pmWords, text) + } + + return nil +} diff --git a/pkg/cli/repl/executor.go b/pkg/cli/repl/executor.go new file mode 100644 index 0000000..0ec939f --- /dev/null +++ b/pkg/cli/repl/executor.go @@ -0,0 +1,48 @@ +package repl + +import ( + "os/exec" + "strings" + + "github.com/LazyBachelor/LazyPM/pkg/cli/commands" +) + +func execute(input string) (string, error) { + if input == "" { + return "", nil + } + + if input == "help" { + return ReplHelp, nil + } + + if input == "title" { + return ReplTitle, nil + } + + if after, ok := strings.CutPrefix(input, "pm"); ok { + return executePMCommand(after) + } + return executeShellCommand(input) +} + +func executeShellCommand(input string) (string, error) { + parts := strings.Fields(input) + if len(parts) == 0 { + return "", nil + } + + cmd := exec.Command(parts[0], parts[1:]...) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func executePMCommand(input string) (string, error) { + parts := strings.Fields(input) + if len(parts) == 0 { + return "", nil + } + + output, err := commands.ExecuteArgsString(parts) + return output, err +} diff --git a/pkg/cli/repl/options.go b/pkg/cli/repl/options.go new file mode 100644 index 0000000..7ba4e63 --- /dev/null +++ b/pkg/cli/repl/options.go @@ -0,0 +1,27 @@ +package repl + +import "github.com/c-bata/go-prompt" + +const PromptPrefix = "> " +const OptionMaxSuggestions = 5 + +const ( + ReplHelp = `Type 'pm help' for available PM commands. +You can also run shell commands directly. Type 'exit' or 'quit' to leave.` + + ReplTitle = "Welcome to Project Management CLI! " + ReplHelp +) + +func promptOptions(history []string) []prompt.Option { + return []prompt.Option{ + prompt.OptionPrefixTextColor(prompt.Cyan), + prompt.OptionMaxSuggestion(OptionMaxSuggestions), + prompt.OptionSuggestionBGColor(prompt.DefaultColor), + prompt.OptionSelectedSuggestionBGColor(prompt.DefaultColor), + prompt.OptionDescriptionBGColor(prompt.DefaultColor), + prompt.OptionSelectedDescriptionBGColor(prompt.DefaultColor), + prompt.OptionPreviewSuggestionBGColor(prompt.DefaultColor), + prompt.OptionScrollbarBGColor(prompt.DefaultColor), + prompt.OptionHistory(history), + } +} diff --git a/pkg/cli/repl/repl.go b/pkg/cli/repl/repl.go index 994404c..b00fa75 100644 --- a/pkg/cli/repl/repl.go +++ b/pkg/cli/repl/repl.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "os/exec" "strings" "github.com/LazyBachelor/LazyPM/internal/service" @@ -12,23 +11,14 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/cli/commands" "github.com/LazyBachelor/LazyPM/pkg/cli/styles" "github.com/c-bata/go-prompt" - "github.com/muesli/reflow/truncate" "golang.org/x/term" ) -const ( - ReplHelp = `Type 'pm help' for available PM commands. -You can also run shell commands directly. Type 'exit' or 'quit' to leave.` - - ReplTitle = "Welcome to Project Management CLI! " + ReplHelp -) - func RunREPL(ctx context.Context, config cli.CLIConfig) error { oldState, err := term.GetState(int(os.Stdin.Fd())) if err != nil { return fmt.Errorf("failed to get terminal state: %w", err) } - defer term.Restore(int(os.Stdin.Fd()), oldState) svc, cleanup, err := service.NewServices(ctx, config) @@ -39,247 +29,31 @@ func RunREPL(ctx context.Context, config cli.CLIConfig) error { commands.SetServices(svc) - fmt.Println("\n" + styles.TitleStyle.Render(ReplTitle)) + fmt.Println(styles.TitleStyle.Render(ReplTitle)) var history []string for { input := prompt.Input( - "› ", + PromptPrefix, completer, - prompt.OptionPrefixTextColor(prompt.Cyan), - prompt.OptionMaxSuggestion(5), - prompt.OptionSuggestionBGColor(prompt.DefaultColor), - prompt.OptionSelectedSuggestionBGColor(prompt.DefaultColor), - prompt.OptionDescriptionBGColor(prompt.DefaultColor), - prompt.OptionSelectedDescriptionBGColor(prompt.DefaultColor), - prompt.OptionPreviewSuggestionBGColor(prompt.DefaultColor), - prompt.OptionScrollbarBGColor(prompt.DefaultColor), - prompt.OptionHistory(history), + promptOptions(history)..., ) input = strings.TrimSpace(input) - if input == "" { - continue - } - if input == "exit" || input == "quit" { fmt.Println("Goodbye!") break } - if input == "help" { - fmt.Println(ReplHelp) - continue - } - - if input == "title" { - fmt.Println(styles.TitleStyle.Render(ReplTitle)) - continue - } - history = append(history, input) - if after, ok := strings.CutPrefix(input, "pm "); ok { - pmCmd := strings.TrimSpace(after) - if pmCmd == "" { - continue - } + // Ignore errors for now, gives better ux + output, _ := execute(input) - args := strings.Fields(pmCmd) - - output, _ := commands.ExecuteArgsString(args) - - if output != "" { - fmt.Println(styles.CommandStyle.Render(output)) - } - - } else { - cmd := exec.Command("sh", "-c", input) - out, err := cmd.CombinedOutput() - - if len(out) > 0 { - fmt.Println(styles.CommandStyle.Render(string(out))) - } - - if err != nil && len(out) == 0 { - fmt.Fprintf(os.Stderr, "%s", fmt.Sprintf("Error: %v", err)) - } - } + fmt.Println(styles.CommandStyle.Render(output)) } return nil } - -func completer(d prompt.Document) []prompt.Suggest { - text := d.TextBeforeCursor() - words := strings.Fields(text) - - if len(words) == 0 { - return nil - } - - if words[0] != "pm" { - suggestions := []prompt.Suggest{ - {Text: "pm", Description: "Project Management System"}, - {Text: "exit", Description: "Exit pm CLI"}, - {Text: "help", Description: "Show help information"}, - {Text: "title", Description: "Print the welcome title"}, - {Text: "git", Description: "Version control system"}, - } - - if len(words) == 0 { - return suggestions - } - - var filtered []prompt.Suggest - for _, s := range suggestions { - if strings.HasPrefix(s.Text, words[0]) { - filtered = append(filtered, s) - } - } - - return filtered - } - - // If first word is "pm", provide PM command completions - if words[0] == "pm" { - // Remove "pm" from words to get the actual command - if len(words) == 1 || (len(words) == 2 && !strings.HasSuffix(text, " ")) { - return commandSuggestions(words[1:]) - } - - // Get the PM subcommand - if len(words) >= 2 { - cmd := words[1] - return flagSuggestions(cmd, words[1:], text) - } - } - - return nil -} - -func commandSuggestions(words []string) []prompt.Suggest { - suggestions := []prompt.Suggest{ - {Text: "help", Description: "Show help information"}, - {Text: "delete", Description: "Delete an issue by ID"}, - {Text: "create", Description: "Create a new issue with title"}, - {Text: "describe", Description: "Get issue details by ID"}, - {Text: "list", Description: "List all issues"}, - } - - if len(words) == 0 { - return suggestions - } - - var filtered []prompt.Suggest - for _, s := range suggestions { - if strings.HasPrefix(s.Text, words[0]) { - filtered = append(filtered, s) - } - } - return filtered -} - -func flagSuggestions(cmd string, words []string, text string) []prompt.Suggest { - var flagSuggests []prompt.Suggest - isCompleteWord := strings.HasSuffix(text, " ") - lastWord := "" - if len(words) > 0 && !isCompleteWord { - lastWord = words[len(words)-1] - } - - switch cmd { - case "create", "add": - flagSuggests = []prompt.Suggest{ - {Text: "--desc", Description: "Issue description"}, - {Text: "--status", Description: "Issue status (open, closed, in_progress)"}, - {Text: "--type", Description: "Issue type (bug, feature, task)"}, - {Text: "--priority", Description: "Issue priority (0-5)"}, - } - return filterAndCompleteFlags(flagSuggests, lastWord, words) - - case "ls", "list", "search": - flagSuggests = []prompt.Suggest{ - {Text: "--title", Description: "Filter by title"}, - {Text: "--desc", Description: "Filter by description"}, - {Text: "--status", Description: "Filter by status (open, closed, in_progress)"}, - {Text: "--type", Description: "Filter by type (bug, feature, task)"}, - {Text: "--priority", Description: "Filter by priority (0-5)"}, - {Text: "--limit", Description: "Limit number of results"}, - } - return filterAndCompleteFlags(flagSuggests, lastWord, words) - - case "describe", "get", "read": - return issueIdSuggestions(words) - - case "delete", "del", "rm", "remove": - return issueIdSuggestions(words) - } - - return nil -} - -func issueIdSuggestions(words []string) []prompt.Suggest { - if len(words) < 2 { - return nil - } - - // Get the partial ID being typed - partial := "" - if len(words) >= 2 { - partial = words[len(words)-1] - } - - issues, _ := commands.GetIssueCompletions(context.Background(), partial) - - var suggestions []prompt.Suggest - for _, issue := range issues { - suggestions = append(suggestions, prompt.Suggest{ - Text: issue.ID, Description: truncate.String(issue.Title, 20), - }) - } - return suggestions -} - -func filterAndCompleteFlags(suggestions []prompt.Suggest, lastWord string, words []string) []prompt.Suggest { - if len(words) >= 2 { - prevWord := words[len(words)-2] - switch prevWord { - case "-s", "--status": - return []prompt.Suggest{ - {Text: "open", Description: "Open status"}, - {Text: "closed", Description: "Closed status"}, - {Text: "in_progress", Description: "In progress status"}, - } - case "-t", "--type": - return []prompt.Suggest{ - {Text: "bug", Description: "Bug issue type"}, - {Text: "feature", Description: "Feature issue type"}, - {Text: "task", Description: "Task issue type"}, - } - case "-p", "--priority": - return []prompt.Suggest{ - {Text: "0", Description: "Lowest priority"}, - {Text: "1", Description: "Low priority"}, - {Text: "2", Description: "Medium-low priority"}, - {Text: "3", Description: "Medium priority"}, - {Text: "4", Description: "High priority"}, - {Text: "5", Description: "Highest priority"}, - } - } - } - - if lastWord == "" { - return suggestions - } - - var filtered []prompt.Suggest - for _, s := range suggestions { - if strings.HasPrefix(s.Text, lastWord) { - filtered = append(filtered, s) - } - } - return filtered -} diff --git a/pkg/cli/repl/suggestions.go b/pkg/cli/repl/suggestions.go new file mode 100644 index 0000000..806e1e1 --- /dev/null +++ b/pkg/cli/repl/suggestions.go @@ -0,0 +1,152 @@ +package repl + +import ( + "context" + "strings" + + "github.com/LazyBachelor/LazyPM/pkg/cli/commands" + "github.com/c-bata/go-prompt" + "github.com/muesli/reflow/truncate" +) + +var rootSuggestions = []prompt.Suggest{ + {Text: "pm", Description: "Project Management System"}, + {Text: "exit", Description: "Exit pm CLI"}, + {Text: "help", Description: "Show help information"}, + {Text: "title", Description: "Print the welcome title"}, + {Text: "git", Description: "Version control system"}, +} + +var baseSuggestions = []prompt.Suggest{ + {Text: "help", Description: "Show help information"}, + {Text: "delete", Description: "Delete an issue by ID"}, + {Text: "create", Description: "Create a new issue with title"}, + {Text: "describe", Description: "Get issue details by ID"}, + {Text: "list", Description: "List all issues"}, +} + +var createFlags = []prompt.Suggest{ + {Text: "--desc", Description: "Issue description"}, + {Text: "--status", Description: "Issue status (open, closed, in_progress)"}, + {Text: "--type", Description: "Issue type (bug, feature, task)"}, + {Text: "--priority", Description: "Issue priority (0-5)"}, +} + +var listFlags = []prompt.Suggest{ + {Text: "--title", Description: "Filter by title"}, + {Text: "--desc", Description: "Filter by description"}, + {Text: "--status", Description: "Filter by status (open, closed, in_progress)"}, + {Text: "--type", Description: "Filter by type (bug, feature, task)"}, + {Text: "--priority", Description: "Filter by priority (0-5)"}, + {Text: "--limit", Description: "Limit number of results"}, +} + +var statusValues = []prompt.Suggest{ + {Text: "open", Description: "Open status"}, + {Text: "closed", Description: "Closed status"}, + {Text: "in_progress", Description: "In progress status"}, +} + +var typeValues = []prompt.Suggest{ + {Text: "bug", Description: "Bug issue type"}, + {Text: "feature", Description: "Feature issue type"}, + {Text: "task", Description: "Task issue type"}, +} + +var priorityValues = []prompt.Suggest{ + {Text: "0", Description: "Lowest priority"}, + {Text: "1", Description: "Low priority"}, + {Text: "2", Description: "Medium-low priority"}, + {Text: "3", Description: "Medium priority"}, + {Text: "4", Description: "High priority"}, + {Text: "5", Description: "Highest priority"}, +} + +func commandSuggestions(words []string) []prompt.Suggest { + if len(words) == 0 { + return baseSuggestions + } + return filterByPrefix(baseSuggestions, words[0]) +} + +func flagSuggestions(cmd string, words []string, text string) []prompt.Suggest { + lastWord, prevWord := parseWords(words, text) + + if values := getFlagValues(prevWord); values != nil { + return filterByPrefix(values, lastWord) + } + + if cmd == "describe" || cmd == "delete" || cmd == "del" || cmd == "rm" || cmd == "remove" || cmd == "get" || cmd == "read" { + return issueIDSuggestions(words) + } + + var flags []prompt.Suggest + switch cmd { + case "create", "add": + flags = createFlags + case "list", "ls", "search": + flags = listFlags + default: + return nil + } + + if lastWord == "" { + return flags + } + return filterByPrefix(flags, lastWord) +} + +func issueIDSuggestions(words []string) []prompt.Suggest { + if len(words) < 2 { + return nil + } + + partial := words[len(words)-1] + issues, _ := commands.GetIssueCompletions(context.Background(), partial) + + var suggestions []prompt.Suggest + for _, issue := range issues { + suggestions = append(suggestions, prompt.Suggest{ + Text: issue.ID, + Description: truncate.String(issue.Title, 20), + }) + } + return suggestions +} + +func parseWords(words []string, text string) (lastWord, prevWord string) { + if len(words) > 0 && !strings.HasSuffix(text, " ") { + lastWord = words[len(words)-1] + if len(words) >= 2 { + prevWord = words[len(words)-2] + } + } else if len(words) >= 1 { + prevWord = words[len(words)-1] + } + return +} + +func getFlagValues(flag string) []prompt.Suggest { + switch flag { + case "-s", "--status": + return statusValues + case "-t", "--type": + return typeValues + case "-p", "--priority": + return priorityValues + } + return nil +} + +func filterByPrefix(suggestions []prompt.Suggest, prefix string) []prompt.Suggest { + if prefix == "" { + return suggestions + } + var filtered []prompt.Suggest + for _, s := range suggestions { + if strings.HasPrefix(s.Text, prefix) { + filtered = append(filtered, s) + } + } + return filtered +} diff --git a/pkg/cli/styles/styles.go b/pkg/cli/styles/styles.go index b080dcc..65a9261 100644 --- a/pkg/cli/styles/styles.go +++ b/pkg/cli/styles/styles.go @@ -5,9 +5,7 @@ import "github.com/charmbracelet/lipgloss" var ( TitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). - Bold(true).Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("12")).Padding(1) + Bold(true).Padding(1) CommandStyle = lipgloss.NewStyle().Padding(1) )