package cmd import ( "fmt" "os" "path/filepath" "plandex-cli/api" "plandex-cli/auth" "plandex-cli/fs" "plandex-cli/lib" "plandex-cli/term" "plandex-cli/types" "plandex-cli/version" shared "plandex-shared" "regexp" "sort" "strings" "unicode" "github.com/fatih/color" "github.com/google/uuid" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" "github.com/plandex-ai/go-prompt" pstrings "github.com/plandex-ai/go-prompt/strings" "github.com/lithammer/fuzzysearch/fuzzy" ) var replCmd = &cobra.Command{ Use: "repl", Short: "Start interactive Plandex REPL", Run: runRepl, } var cliSuggestions []prompt.Suggest var projectPaths *types.ProjectPaths var currentPrompt *prompt.Prompt var replConfig *shared.PlanConfig var sessionId string func init() { RootCmd.AddCommand(replCmd) replCmd.Flags().BoolP("chat", "c", false, "Start in chat mode") replCmd.Flags().BoolP("tell", "t", false, "Start in tell mode") AddNewPlanFlags(replCmd) for _, config := range term.CliCommands { if config.Repl { desc := config.Desc if config.Alias != "" { desc = fmt.Sprintf("(\\%s) %s", config.Alias, desc) } cliSuggestions = append(cliSuggestions, prompt.Suggest{Text: "\\" + config.Cmd, Description: desc}) } } } func setReplConfig() { replConfig = lib.MustGetCurrentPlanConfig() } func runRepl(cmd *cobra.Command, args []string) { sessionId = uuid.New().String() term.SetIsRepl(true) auth.MustResolveAuthWithOrg() lib.MustResolveOrCreateProject() term.StartSpinner("") lib.LoadState() chatFlag, err := cmd.Flags().GetBool("chat") if err != nil { term.OutputErrorAndExit("Error getting chat flag: %v", err) } tellFlag, err := cmd.Flags().GetBool("tell") if err != nil { term.OutputErrorAndExit("Error getting tell flag: %v", err) } if chatFlag && tellFlag { term.OutputErrorAndExit("Cannot specify both --chat and --tell flags") } if chatFlag { lib.CurrentReplState.Mode = lib.ReplModeChat lib.WriteState() } else if tellFlag { lib.CurrentReplState.Mode = lib.ReplModeTell lib.WriteState() } afterNew := false if lib.CurrentPlanId == "" { os.Setenv("PLANDEX_DISABLE_SUGGESTIONS", "1") args := []string{} if noAuto { args = append(args, "--no-auto") } else if basicAuto { args = append(args, "--basic") } else if plusAuto { args = append(args, "--plus") } else if semiAuto { args = append(args, "--semi") } else if fullAuto { args = append(args, "--full") } if ossModels { args = append(args, "--oss") } else if strongModels { args = append(args, "--strong") } else if cheapModels { args = append(args, "--cheap") } else if dailyModels { args = append(args, "--daily") } else if reasoningModels { args = append(args, "--reasoning") } else if geminiPlannerModels { args = append(args, "--gemini-planner") } else if o3PlannerModels { args = append(args, "--o3-planner") } else if r1PlannerModels { args = append(args, "--r1-planner") } else if perplexityPlannerModels { args = append(args, "--perplexity-planner") } else if opusPlannerModels { args = append(args, "--opus-planner") } newCmd.Run(newCmd, args) os.Setenv("PLANDEX_DISABLE_SUGGESTIONS", "") afterNew = true } setReplConfig() lib.MustVerifyAuthVars(auth.Current.IntegratedModelsMode) projectPaths, err = fs.GetProjectPaths(fs.Cwd) if err != nil { color.New(term.ColorHiRed).Printf("Error getting project paths: %v\n", err) } settings, apiErr := api.Client.GetSettings(lib.CurrentPlanId, lib.CurrentBranch) if apiErr != nil { term.OutputErrorAndExit("Error getting settings: %v", apiErr.Msg) } var printAutoFn func() var printModelFn func() if !afterNew { var didUpdateConfig bool var updatedConfig *shared.PlanConfig var updatedSettings *shared.PlanSettings didUpdateConfig, updatedConfig, printAutoFn = resolveAutoModeSilent(replConfig) updatedSettings, printModelFn = resolveModelPackSilent(settings) if didUpdateConfig { loadMapIfNeeded(replConfig, updatedConfig) removeMapIfNeeded(replConfig, updatedConfig) if updatedConfig != nil { replConfig = updatedConfig } } if updatedSettings != nil { settings = updatedSettings } } replWelcome(replWelcomeParams{ afterNew: afterNew, isHelp: false, printAutoFn: printAutoFn, printModelFn: printModelFn, config: replConfig, packName: settings.GetModelPack().Name, }) var p *prompt.Prompt p = prompt.New( func(in string) { executor(in, p) }, prompt.WithPrefixCallback(func() string { // Get last part of current working directory // cwd := fs.Cwd // dirName := filepath.Base(cwd) // Build prefix with directory and mode indicator var modeIcon string if lib.CurrentReplState.Mode == lib.ReplModeTell { modeIcon = "⚡️" if replConfig.AutoApply && replConfig.AutoExec { modeIcon += "❗️" // warning reminder for auto apply and auto exec } } else if lib.CurrentReplState.Mode == lib.ReplModeChat { modeIcon = "💬" } return fmt.Sprintf("%s ", modeIcon) }), prompt.WithTitle("Plandex "+version.Version), prompt.WithSelectedSuggestionBGColor(prompt.LightGray), prompt.WithSuggestionBGColor(prompt.DarkGray), prompt.WithCompletionOnDown(), prompt.WithCompleter(completer), prompt.WithExecuteOnEnterCallback(executeOnEnter), prompt.WithHistory(lib.GetHistory()), ) currentPrompt = p p.Run() } func getSuggestions() []prompt.Suggest { suggestions := []prompt.Suggest{} if lib.CurrentReplState.IsMulti { suggestions = append(suggestions, []prompt.Suggest{ {Text: "\\send", Description: "(\\s) Send the current prompt"}, {Text: "\\multi", Description: "(\\m) Turn multi-line mode off"}, {Text: "\\run", Description: "(\\r) Run a file through tell/chat based on current mode"}, {Text: "\\quit", Description: "(\\q) Exit the REPL"}, }...) } if lib.CurrentReplState.Mode == lib.ReplModeTell { suggestions = append(suggestions, []prompt.Suggest{ {Text: "\\chat", Description: "(\\ch) Switch to 'chat' mode to have a conversation without making changes"}, }...) } else if lib.CurrentReplState.Mode == lib.ReplModeChat { suggestions = append(suggestions, []prompt.Suggest{ {Text: "\\tell", Description: "(\\t) Switch to 'tell' mode for implementation"}, }...) } if !lib.CurrentReplState.IsMulti { suggestions = append(suggestions, []prompt.Suggest{ {Text: "\\multi", Description: "(\\m) Turn multi-line mode on"}, {Text: "\\run", Description: "(\\r) Run a file through tell/chat based on current mode"}, {Text: "\\quit", Description: "(\\q) Exit the REPL"}, }...) } // Add help command suggestion suggestions = append(suggestions, prompt.Suggest{Text: "\\help", Description: "(\\h) REPL info and list of commands"}) suggestions = append(suggestions, cliSuggestions...) for path := range projectPaths.ActivePaths { if path == "." { continue } isDir := projectPaths.ActiveDirs[path] if isDir { path += "/" } suggestions = append(suggestions, prompt.Suggest{Text: "@" + path}) loadArgs := path if isDir { loadArgs += " -r" } suggestions = append(suggestions, prompt.Suggest{Text: "\\load " + loadArgs}) if isDir { loadArgs = path loadArgs += " --map" suggestions = append(suggestions, prompt.Suggest{Text: "\\load " + loadArgs}) loadArgs = path loadArgs += " --tree" suggestions = append(suggestions, prompt.Suggest{Text: "\\load " + loadArgs}) } if filepath.Ext(path) == ".md" || filepath.Ext(path) == ".txt" { suggestions = append(suggestions, prompt.Suggest{Text: "\\run " + path}) } } return suggestions } func executeOnEnter(p *prompt.Prompt, indentSize int) (int, bool) { input := p.Buffer().Text() cmd, _ := parseCommand(input) if cmd != "" { return 0, true } if lib.CurrentReplState.IsMulti { return 0, false } return 0, true } const cancelOpt = "Cancel" func executor(in string, p *prompt.Prompt) { defer lib.WriteHistory(in) in = strings.TrimSpace(in) lines := strings.Split(in, "\n") lastLine := lines[len(lines)-1] lastLine = strings.TrimSpace(lastLine) trimmedInput := strings.TrimSpace(in) if trimmedInput == "" { return } // condense whitespace condensedInput := strings.Join(strings.Fields(trimmedInput), " ") // Handle plandex/pdx command prefix if strings.HasPrefix(lastLine, "plandex ") || strings.HasPrefix(lastLine, "pdx ") { fmt.Println() parts := strings.Fields(lastLine) if len(parts) > 1 { args := parts[1:] // Skip the "plandex" or "pdx" command _, err := lib.ExecPlandexCommandWithParams(args, lib.ExecPlandexCommandParams{ SessionId: sessionId, }) if err != nil { color.New(term.ColorHiRed).Printf("Error executing command: %v\n", err) } } fmt.Println() return } // Find the last \ or @ in the last line lastBackslashIndex := strings.LastIndex(lastLine, "\\") lastAtIndex := strings.LastIndex(lastLine, "@") var preservedBuffer string if len(lines) > 1 { preservedBuffer = strings.Join(lines[:len(lines)-1], "\n") + "\n" } suggestions, _, _ := completer(prompt.Document{Text: in}) // Handle file references if lastAtIndex != -1 && lastAtIndex > lastBackslashIndex { paths := strings.Split(lastLine, "@") numPaths := len(paths) filteredPaths := []string{} for i, path := range paths { p := strings.TrimSpace(path) if i == 0 { // text before the @ preservedBuffer += p + " " continue } if (p == "" || !projectPaths.ActivePaths[p]) && len(suggestions) > 0 && i == numPaths-1 { p = strings.Replace(suggestions[0].Text, "@", "", 1) filteredPaths = append(filteredPaths, p) } else if projectPaths.ActivePaths[p] { filteredPaths = append(filteredPaths, p) } } if len(filteredPaths) > 0 { args := []string{"load"} args = append(args, filteredPaths...) args = append(args, "-r") fmt.Println() _, err := lib.ExecPlandexCommandWithParams(args, lib.ExecPlandexCommandParams{ SessionId: sessionId, }) if err != nil { color.New(term.ColorHiRed).Printf("Error executing command: %v\n", err) } fmt.Println() if preservedBuffer != "" { p.InsertTextMoveCursor(preservedBuffer, true) } return } } // Handle commands if lastBackslashIndex != -1 { cmdString := strings.TrimSpace(lastLine[lastBackslashIndex+1:]) if cmdString == "" { return } res := execWithInput(execWithInputParams{ cmdString: cmdString, in: condensedInput, lastBackslashIndex: lastBackslashIndex, preservedBuffer: preservedBuffer, p: p, lastLine: lastLine, condensedInput: condensedInput, trimmedInput: trimmedInput, lines: lines, suggestions: suggestions, }) if res.shouldReturn { return } condensedInput = res.condensedInput trimmedInput = res.trimmedInput } else if len(lines) == 1 { // Check for likely accidental command inputs (with no backslash) and confirm with user var allCommands []string for replCmd := range lib.ReplCmdAliases { allCommands = append(allCommands, replCmd) } for _, config := range term.CliCommands { if config.Repl { allCommands = append(allCommands, config.Cmd) } } // Only suggest commands if they're close enough matches maybeCmds := findSimilarCommands(lastLine, allCommands) if len(maybeCmds) > 0 { res := suggestCmds(maybeCmds, getPromptOpt(lastLine)) if res.shouldReturn { return } matchedCmd := res.matchedCmd if matchedCmd != "" { res := execWithInput(execWithInputParams{ cmdString: matchedCmd, in: condensedInput, lastBackslashIndex: lastBackslashIndex, preservedBuffer: preservedBuffer, p: p, lastLine: lastLine, condensedInput: condensedInput, trimmedInput: trimmedInput, lines: lines, suggestions: suggestions, }) if res.shouldReturn { return } condensedInput = res.condensedInput trimmedInput = res.trimmedInput } } } // Handle non-command input based on mode if lib.CurrentReplState.Mode == lib.ReplModeTell { fmt.Println() args := []string{"tell", trimmedInput} var err error _, err = lib.ExecPlandexCommandWithParams(args, lib.ExecPlandexCommandParams{ SessionId: sessionId, }) if err != nil { color.New(term.ColorHiRed).Printf("Error executing tell: %v\n", err) } } else if lib.CurrentReplState.Mode == lib.ReplModeChat { fmt.Println() args := []string{"chat", trimmedInput} output, err := lib.ExecPlandexCommandWithParams(args, lib.ExecPlandexCommandParams{ SessionId: sessionId, }) if err != nil { color.New(term.ColorHiRed).Printf("Error executing chat: %v\n", err) } replacer := strings.NewReplacer("'", "", "\"", "", "*", "", "`", "", "_", "") output = replacer.Replace(output) rx := regexp.MustCompile(`(?i)(switch|start|continue|begin|change|chang|move|proceed|go|transition)(ing)?( to | with | into )?(tell|implementation|coding|development)( mode)?`) if rx.MatchString(output) { fmt.Println() res, err := term.ConfirmYesNo("Switch to tell mode for implementation?") if err != nil { color.New(term.ColorHiRed).Printf("Error confirming yes/no: %v\n", err) } if res { lib.CurrentReplState.Mode = lib.ReplModeTell lib.WriteState() fmt.Println() color.New(color.BgMagenta, color.FgHiWhite, color.Bold).Println(" ⚡️ Tell mode is enabled ") fmt.Println() fmt.Println("Now that you're in tell mode, you can either begin the implementation based on the conversation so far, or you can send another prompt to begin the implementation with additional information or instructions.") fmt.Println() beginImplOpt := "Begin implementation" anotherPromptOpt := "Send another prompt" sel, err := term.SelectFromList("What would you like to do?", []string{beginImplOpt, anotherPromptOpt}) if err != nil { color.New(term.ColorHiRed).Printf("Error selecting from list: %v\n", err) } if sel == beginImplOpt { fmt.Println() args := []string{"tell", "--from-chat"} _, err := lib.ExecPlandexCommandWithParams(args, lib.ExecPlandexCommandParams{ SessionId: sessionId, }) if err != nil { color.New(term.ColorHiRed).Printf("Error executing tell: %v\n", err) } } } } } fmt.Println() } func completer(in prompt.Document) ([]prompt.Suggest, pstrings.RuneNumber, pstrings.RuneNumber) { // Don't show suggestions if we're navigating history if currentPrompt.IsNavigatingHistory() { return []prompt.Suggest{}, 0, 0 } endIndex := in.CurrentRuneIndex() lines := strings.Split(in.Text, "\n") currentLineNum := strings.Count(in.TextBeforeCursor(), "\n") // Don't show suggestions if we're not on the last line if currentLineNum < len(lines)-1 { return []prompt.Suggest{}, 0, 0 } lastLine := lines[len(lines)-1] if strings.TrimSpace(lastLine) == "" && len(lines) > 1 { lastLine = lines[len(lines)-2] } // Handle plandex/pdx command prefix if strings.HasPrefix(lastLine, "plandex ") || strings.HasPrefix(lastLine, "pdx ") { parts := strings.Fields(lastLine) var prefix string if len(parts) > 1 { prefix = parts[len(parts)-1] } startIndex := endIndex - pstrings.RuneNumber(len(prefix)) suggestions := []prompt.Suggest{} for _, config := range term.CliCommands { suggestions = append(suggestions, prompt.Suggest{ Text: config.Cmd, Description: config.Desc, }) } filtered := prompt.FilterFuzzy(suggestions, prefix, true) return filtered, startIndex, endIndex } // Find the last valid \ or @ in the current line lastBackslashIndex := -1 lastAtIndex := -1 // Helper function to check if character at index is valid (start of line or after space) isValidPosition := func(str string, index int) bool { if index <= 0 { return true // Start of line } return unicode.IsSpace(rune(str[index-1])) // After whitespace } // Find last valid backslash for i := len(lastLine) - 1; i >= 0; i-- { if lastLine[i] == '\\' && isValidPosition(lastLine, i) { lastBackslashIndex = i break } } // Find last valid @ for i := len(lastLine) - 1; i >= 0; i-- { if lastLine[i] == '@' && isValidPosition(lastLine, i) { lastAtIndex = i break } } var w string var startIndex pstrings.RuneNumber if lastBackslashIndex == -1 && lastAtIndex == -1 { return []prompt.Suggest{}, 0, 0 } // Use the rightmost special character if lastBackslashIndex > lastAtIndex { // Get everything after the last backslash w = lastLine[lastBackslashIndex:] startIndex = endIndex - pstrings.RuneNumber(len(w)) } else if lastAtIndex != -1 { // Get everything after the last @ w = lastLine[lastAtIndex:] startIndex = endIndex - pstrings.RuneNumber(len(w)) } // Verify this is at the end of the line (allowing for trailing spaces) if !strings.HasSuffix(strings.TrimSpace(lastLine), strings.TrimSpace(w)) { return []prompt.Suggest{}, 0, 0 } wTrimmed := strings.TrimSpace(strings.TrimPrefix(w, "\\")) parts := strings.Split(wTrimmed, " ") wCmd := parts[0] // For commands, verify it starts with an actual command if strings.HasPrefix(w, "\\") { isValidCommand := false for _, config := range term.CliCommands { if !config.Repl { continue } if strings.HasPrefix(config.Cmd, wCmd) || (config.Alias != "" && strings.HasPrefix(config.Alias, wCmd)) { isValidCommand = true break } } // Also check built-in REPL commands if strings.HasPrefix("quit", wCmd) || strings.HasPrefix("multi", wCmd) || strings.HasPrefix("tell", wCmd) || strings.HasPrefix("chat", wCmd) || strings.HasPrefix("send", wCmd) || strings.HasPrefix("run", wCmd) { isValidCommand = true } if !isValidCommand && wCmd != "" { return []prompt.Suggest{}, 0, 0 } } fuzzySuggestions := prompt.FilterFuzzy(getSuggestions(), w, true) prefixMatches := prompt.FilterHasPrefix(getSuggestions(), w, true) runFilteredFuzzy := []prompt.Suggest{} runFilteredPrefixMatches := []prompt.Suggest{} for _, s := range fuzzySuggestions { if strings.HasPrefix(s.Text, "\\run ") { if wCmd == "run" { runFilteredFuzzy = append(runFilteredFuzzy, s) } } else { runFilteredFuzzy = append(runFilteredFuzzy, s) } } for _, s := range prefixMatches { if strings.HasPrefix(s.Text, "\\run ") { if wCmd == "run" { runFilteredPrefixMatches = append(runFilteredPrefixMatches, s) } } else { runFilteredPrefixMatches = append(runFilteredPrefixMatches, s) } } fuzzySuggestions = runFilteredFuzzy prefixMatches = runFilteredPrefixMatches loadFilteredFuzzy := []prompt.Suggest{} loadFilteredPrefixMatches := []prompt.Suggest{} for _, s := range fuzzySuggestions { if strings.HasPrefix(s.Text, "\\load ") { if wCmd == "load" { loadFilteredFuzzy = append(loadFilteredFuzzy, s) } } else { loadFilteredFuzzy = append(loadFilteredFuzzy, s) } } for _, s := range prefixMatches { if strings.HasPrefix(s.Text, "\\load ") { if wCmd == "load" { loadFilteredPrefixMatches = append(loadFilteredPrefixMatches, s) } } else { loadFilteredPrefixMatches = append(loadFilteredPrefixMatches, s) } } fuzzySuggestions = loadFilteredFuzzy prefixMatches = loadFilteredPrefixMatches if strings.TrimSpace(w) != "\\" { sort.Slice(prefixMatches, func(i, j int) bool { iTxt := prefixMatches[i].Text jTxt := prefixMatches[j].Text if iTxt == "\\chat" || iTxt == "\\tell" || iTxt == "\\multi" || iTxt == "\\quit" || iTxt == "\\send" || iTxt == "\\run" { return true } if jTxt == "\\chat" || jTxt == "\\tell" || jTxt == "\\multi" || jTxt == "\\quit" || jTxt == "\\send" || jTxt == "\\run" { return false } return prefixMatches[i].Text < prefixMatches[j].Text }) } if len(prefixMatches) > 0 { // Remove prefix matches from fuzzy results to avoid duplicates prefixMatchSet := make(map[string]bool) for _, s := range prefixMatches { prefixMatchSet[s.Text] = true } nonPrefixFuzzy := make([]prompt.Suggest, 0) for _, s := range fuzzySuggestions { if !prefixMatchSet[s.Text] { nonPrefixFuzzy = append(nonPrefixFuzzy, s) } } fuzzySuggestions = append(prefixMatches, nonPrefixFuzzy...) } var aliasMatch string if lib.ReplCmdAliases[wTrimmed] != "" { aliasMatch = "\\" + lib.ReplCmdAliases[wTrimmed] } else { for _, s := range term.CliCommands { if s.Alias == wTrimmed { aliasMatch = "\\" + s.Cmd break } } } if aliasMatch != "" { // put the suggestion with the alias match at the beginning var matched prompt.Suggest found := false for _, s := range fuzzySuggestions { if s.Text == aliasMatch { matched = s found = true break } } if found { newSuggestions := []prompt.Suggest{} newSuggestions = append(newSuggestions, matched) for _, s := range fuzzySuggestions { if s.Text != aliasMatch { newSuggestions = append(newSuggestions, s) } } fuzzySuggestions = newSuggestions } } return fuzzySuggestions, startIndex, endIndex } type replWelcomeParams struct { afterNew bool isHelp bool printAutoFn func() printModelFn func() packName string config *shared.PlanConfig } func replWelcome(params replWelcomeParams) { // print REPL welcome message and basic info // have to make these requests serially in case re-authentication is needed afterNew := params.afterNew isHelp := params.isHelp printAutoFn := params.printAutoFn printModelFn := params.printModelFn packName := params.packName plan, apiErr := api.Client.GetPlan(lib.CurrentPlanId) if apiErr != nil { term.OutputErrorAndExit("Error getting plan: %v", apiErr.Msg) } config := params.config if config == nil { config, apiErr = api.Client.GetPlanConfig(lib.CurrentPlanId) if apiErr != nil { term.OutputErrorAndExit("Error getting plan config: %v", apiErr.Msg) } } currentBranchesByPlanId, err := api.Client.GetCurrentBranchByPlanId(lib.CurrentProjectId, shared.GetCurrentBranchByPlanIdRequest{ CurrentBranchByPlanId: map[string]string{ lib.CurrentPlanId: lib.CurrentBranch, }, }) if err != nil { term.OutputErrorAndExit("Error getting current branches: %v", err) } term.StopSpinner() if !afterNew { fmt.Println() } color.New(color.FgHiWhite, color.BgBlue, color.Bold).Print(" 👋 Welcome to Plandex ") versionStr := version.Version if versionStr != "development" { color.New(color.FgHiWhite, color.BgHiBlack).Printf(" v%s ", versionStr) } fmt.Println() fmt.Println() fmt.Println(lib.GetCurrentPlanTable(plan, currentBranchesByPlanId, nil)) table := tablewriter.NewWriter(os.Stdout) table.SetAutoWrapText(false) var contextMode string if config.AutoLoadContext { contextMode = "auto" } else { contextMode = "manual" } filesStr := "%s for loading files into context" if contextMode == "auto" { filesStr += " manually (optional)" } filesStr += "\n" color.New(color.FgHiWhite).Printf("%s for commands\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\")) color.New(color.FgHiWhite).Printf(filesStr, color.New(term.ColorHiCyan, color.Bold).Sprint("@")) color.New(color.FgHiWhite).Printf("%s (\\h) for help\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\help")) color.New(color.FgHiWhite).Printf("%s (\\q) to exit\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\quit")) fmt.Println() if printAutoFn != nil { printAutoFn() } else { printAutoModeTable(config) } color.New(color.FgHiWhite).Printf("%s to change auto mode\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\set-auto")) color.New(color.FgHiWhite).Printf("%s to see config\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\config")) color.New(color.FgHiWhite).Printf("%s to customize config\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\set-config")) fmt.Println() if printModelFn != nil { printModelFn() } else { printModelPackTable(packName) } color.New(color.FgHiWhite).Printf("%s to see model details\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\models")) color.New(color.FgHiWhite).Printf("%s to change models\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\set-model")) showReplMode() showMultiLineMode() fmt.Println() if !isHelp { if lib.CurrentReplState.Mode == lib.ReplModeTell { color.New(color.FgHiWhite, color.BgBlue, color.Bold).Println(" Describe a coding task 👇 ") } else { color.New(color.FgHiWhite, color.BgBlue, color.Bold).Println(" Ask a question or chat 👇 ") } fmt.Println() } } func replHelp() { replWelcome(replWelcomeParams{ afterNew: false, isHelp: true, }) term.PrintHelpAllCommands() } func showReplMode() { fmt.Println() if lib.CurrentReplState.Mode == lib.ReplModeTell { color.New(color.BgMagenta, color.FgHiWhite, color.Bold).Println(" ⚡️ Tell mode is enabled ") color.New(color.FgHiWhite).Printf("%s (\\ch) switch to chat mode to chat without writing code or making changes\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\chat")) } else if lib.CurrentReplState.Mode == lib.ReplModeChat { color.New(color.BgMagenta, color.FgHiWhite, color.Bold).Println(" 💬 Chat mode is enabled ") color.New(color.FgHiWhite).Printf("%s (\\t) switch to tell mode to start writing code and implementing tasks\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\tell")) } fmt.Println() } func showMultiLineMode() { if lib.CurrentReplState.IsMulti { color.New(color.BgMagenta, color.FgHiWhite, color.Bold).Println(" 🔢 Multi-line mode is enabled ") fmt.Printf("%s to exit multi-line mode\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\multi")) fmt.Printf("%s for line breaks\n", color.New(term.ColorHiCyan, color.Bold).Sprint("enter")) fmt.Printf("%s to send prompt\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\send")) } else { color.New(color.BgMagenta, color.FgHiWhite, color.Bold).Println(" 1️⃣ Multi-line mode is disabled ") fmt.Printf("%s for multi-line editing mode\n", color.New(term.ColorHiCyan, color.Bold).Sprint("\\multi")) fmt.Printf("%s to send prompt\n", color.New(term.ColorHiCyan, color.Bold).Sprint("enter")) } } func parseCommand(in string) (string, string) { in = strings.TrimSpace(in) lines := strings.Split(in, "\n") lastLine := lines[len(lines)-1] lastLine = strings.TrimSpace(lastLine) input := strings.TrimSpace(in) if input == "" { return "", "" } // Handle plandex/pdx command prefix if strings.HasPrefix(lastLine, "plandex ") || strings.HasPrefix(lastLine, "pdx ") { return lastLine, lastLine } // Find the last \ or @ in the last line lastBackslashIndex := strings.LastIndex(lastLine, "\\") lastAtIndex := strings.LastIndex(lastLine, "@") suggestions, _, _ := completer(prompt.Document{Text: in}) // Handle file references if lastAtIndex != -1 && lastAtIndex > lastBackslashIndex { paths := strings.Split(lastLine, "@") split2 := strings.SplitN(lastLine, "@", 2) numPaths := len(paths) filteredPaths := []string{} for i, path := range paths { p := strings.TrimSpace(path) if i == 0 { // text before the @ continue } if (p == "" || !projectPaths.ActivePaths[p]) && len(suggestions) > 0 && i == numPaths-1 { p = strings.Replace(suggestions[0].Text, "@", "", 1) filteredPaths = append(filteredPaths, p) } else if projectPaths.ActivePaths[p] { filteredPaths = append(filteredPaths, p) } } if len(filteredPaths) > 0 { res := "" for _, p := range filteredPaths { res += "@" + p + " " } return res, split2[1] } } // Handle commands if lastBackslashIndex != -1 { cmdString := strings.TrimSpace(lastLine[lastBackslashIndex+1:]) if cmdString == "" { return "", "" } // Split into command and args parts := strings.Fields(cmdString) cmd := parts[0] args := parts[1:] // Handle built-in REPL commands switch cmd { case "quit", lib.ReplCmdAliases["quit"]: return "\\quit", "\\" + cmdString case "help", lib.ReplCmdAliases["help"]: return "\\help", "\\" + cmdString case "multi", lib.ReplCmdAliases["multi"]: return "\\multi", "\\" + cmdString case "send", lib.ReplCmdAliases["send"]: return "\\send", "\\" + cmdString case "tell", lib.ReplCmdAliases["tell"]: return "\\tell", "\\" + cmdString case "chat", lib.ReplCmdAliases["chat"]: return "\\chat", "\\" + cmdString case "run", lib.ReplCmdAliases["run"]: return "\\run", "\\" + cmdString default: // Check CLI commands var matchedCmd string for _, config := range term.CliCommands { if (cmd == config.Cmd || (config.Alias != "" && cmd == config.Alias)) && config.Repl { matchedCmd = config.Cmd break } } if matchedCmd == "" { for _, config := range term.CliCommands { if strings.HasPrefix(config.Cmd, cmd) && config.Repl { matchedCmd = config.Cmd break } } } if matchedCmd != "" { res := matchedCmd if len(args) > 0 { res += " " + strings.Join(args, " ") } return res, "\\" + cmdString } } } return "", "" } func isFileInProjectPaths(filePath string) bool { // Convert to absolute path absPath, err := filepath.Abs(filePath) if err != nil { return false } // Check if file is within any project path for path := range projectPaths.ActivePaths { projectAbs, err := filepath.Abs(path) if err != nil { continue } if strings.HasPrefix(absPath, projectAbs) { return true } } return false } func handleRunCommand(args []string) error { if len(args) != 1 { return fmt.Errorf("run command requires exactly one file path argument") } filePath := args[0] // Check if file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { return fmt.Errorf("file does not exist: %s", filePath) } // Build command based on current mode var cmdArgs []string if lib.CurrentReplState.Mode == lib.ReplModeTell { cmdArgs = []string{"tell", "-f", filePath} } else { cmdArgs = []string{"chat", "-f", filePath} } // Execute the command _, err := lib.ExecPlandexCommand(cmdArgs) if err != nil { return fmt.Errorf("error executing command: %v", err) } return nil } func getPromptOpt(cmd string) string { asPrompt := cmd if len(asPrompt) > 20 { asPrompt = asPrompt[:20] + "..." } return fmt.Sprintf("Send '%s' as a prompt to the AI model", asPrompt) } type suggestCmdsResult struct { shouldReturn bool matchedCmd string } func suggestCmds(cmds []string, promptOpt string) suggestCmdsResult { var matchedCmd string fmt.Println() opts := []string{} for _, match := range cmds { opts = append(opts, "\\"+match) } opts = append(opts, cancelOpt, promptOpt) sel, err := term.SelectFromList("🤔 Did you mean to type one of these commands?", opts) if err != nil { color.New(term.ColorHiRed).Printf("Error selecting from list: %v\n", err) } if sel == cancelOpt { return suggestCmdsResult{shouldReturn: true} } else if sel != promptOpt { matchedCmd = strings.Replace(sel, "\\", "", 1) } return suggestCmdsResult{matchedCmd: matchedCmd} } type execWithInputParams struct { cmdString string in string lastBackslashIndex int preservedBuffer string p *prompt.Prompt lastLine string condensedInput string trimmedInput string lines []string suggestions []prompt.Suggest } type execWithInputResult struct { shouldReturn bool condensedInput string trimmedInput string } func execWithInput(params execWithInputParams) execWithInputResult { cmdString := params.cmdString in := params.in lastBackslashIndex := params.lastBackslashIndex preservedBuffer := params.preservedBuffer lastLine := params.lastLine p := params.p condensedInput := params.condensedInput trimmedInput := params.trimmedInput lines := params.lines suggestions := params.suggestions // Split into command and args parts := strings.Fields(cmdString) cmd := parts[0] args := parts[1:] var fuzzyNEQCheckCmds []string for replCmd := range lib.ReplCmdAliases { if replCmd != cmd { fuzzyNEQCheckCmds = append(fuzzyNEQCheckCmds, replCmd) } } for _, config := range term.CliCommands { if !config.Repl { continue } if config.Cmd != cmd { fuzzyNEQCheckCmds = append(fuzzyNEQCheckCmds, config.Cmd) } } fuzzyNEQMatches := findSimilarCommands(cmd, fuzzyNEQCheckCmds) // Handle built-in REPL commands switch { case cmd == "quit" || cmd == lib.ReplCmdAliases["quit"]: lib.WriteHistory(in) os.Exit(0) case cmd == "help" || cmd == lib.ReplCmdAliases["help"]: if lastBackslashIndex > 0 { preservedBuffer += lastLine[:lastBackslashIndex] } replHelp() fmt.Println() if preservedBuffer != "" { p.InsertTextMoveCursor(preservedBuffer, true) } return execWithInputResult{shouldReturn: true} case cmd == "multi" || cmd == lib.ReplCmdAliases["multi"]: if lastBackslashIndex > 0 { preservedBuffer += lastLine[:lastBackslashIndex] } fmt.Println() lib.CurrentReplState.IsMulti = !lib.CurrentReplState.IsMulti showMultiLineMode() lib.WriteState() fmt.Println() if preservedBuffer != "" { p.InsertTextMoveCursor(preservedBuffer, true) } return execWithInputResult{shouldReturn: true} case cmd == "send" || cmd == lib.ReplCmdAliases["send"]: condensedSplit := strings.Split(condensedInput, "\\s") condensedInput = strings.TrimSpace(condensedSplit[0]) condensedInput = strings.TrimSpace(condensedInput) trimmedSplit := strings.Split(trimmedInput, "\\s") trimmedInput = strings.TrimSpace(trimmedSplit[0]) trimmedInput = strings.TrimSpace(trimmedInput) if condensedInput == "" { fmt.Println() fmt.Println("🤷‍♂️ No prompt to send") fmt.Println() return execWithInputResult{shouldReturn: true} } case cmd == "tell" || cmd == lib.ReplCmdAliases["tell"]: if lastBackslashIndex > 0 { preservedBuffer += lastLine[:lastBackslashIndex] } lib.CurrentReplState.Mode = lib.ReplModeTell lib.WriteState() showReplMode() if preservedBuffer != "" { p.InsertTextMoveCursor(preservedBuffer, true) } return execWithInputResult{shouldReturn: true} case cmd == "chat" || cmd == lib.ReplCmdAliases["chat"]: if lastBackslashIndex > 0 { preservedBuffer += lastLine[:lastBackslashIndex] } lib.CurrentReplState.Mode = lib.ReplModeChat lib.WriteState() showReplMode() if preservedBuffer != "" { p.InsertTextMoveCursor(preservedBuffer, true) } return execWithInputResult{shouldReturn: true} case cmd == "run" || cmd == lib.ReplCmdAliases["run"]: if lastBackslashIndex > 0 { preservedBuffer += lastLine[:lastBackslashIndex] } fmt.Println() if err := handleRunCommand(args); err != nil { color.New(term.ColorHiRed).Printf("Run command failed: %v\n", err) } fmt.Println() if preservedBuffer != "" { p.InsertTextMoveCursor(preservedBuffer, true) } return execWithInputResult{shouldReturn: true} default: // Check CLI commands var matchedCmd string for _, config := range term.CliCommands { if (cmd == config.Cmd || (config.Alias != "" && cmd == config.Alias)) && config.Repl { matchedCmd = config.Cmd break } } if matchedCmd == "" && len(suggestions) > 0 { matchedCmd = strings.Replace(suggestions[0].Text, "\\", "", 1) return execWithInput(execWithInputParams{ cmdString: matchedCmd, in: condensedInput, lastBackslashIndex: lastBackslashIndex, preservedBuffer: preservedBuffer, p: p, lastLine: lastLine, condensedInput: condensedInput, trimmedInput: trimmedInput, lines: lines, suggestions: suggestions, }) } if matchedCmd == "" { promptOpt := getPromptOpt(cmd) if len(fuzzyNEQMatches) > 0 { res := suggestCmds(fuzzyNEQMatches, promptOpt) if res.shouldReturn { return execWithInputResult{shouldReturn: true} } matchedCmd = res.matchedCmd return execWithInput(execWithInputParams{ cmdString: matchedCmd, in: condensedInput, lastBackslashIndex: lastBackslashIndex, preservedBuffer: preservedBuffer, p: p, lastLine: lastLine, condensedInput: condensedInput, trimmedInput: trimmedInput, lines: lines, suggestions: suggestions, }) } else if len(lines) == 1 && strings.HasPrefix(trimmedInput, "\\") { showCmdsOpt := "Show available commands" opts := []string{cancelOpt, showCmdsOpt, promptOpt} sel, err := term.SelectFromList("🤔 Couldn't find a matching command. What do you want to do?", opts) if err != nil { color.New(term.ColorHiRed).Printf("Error selecting from list: %v\n", err) } if sel == cancelOpt { return execWithInputResult{shouldReturn: true} } else if sel == showCmdsOpt { replHelp() fmt.Println() return execWithInputResult{shouldReturn: true} } } } if matchedCmd != "" { // fmt.Println("> plandex " + config.Cmd) if lastBackslashIndex > 0 { preservedBuffer += lastLine[:lastBackslashIndex] } fmt.Println() execArgs := []string{matchedCmd} if matchedCmd == "continue" && chatOnly { execArgs = append(execArgs, "--chat") } execArgs = append(execArgs, args...) _, err := lib.ExecPlandexCommandWithParams(execArgs, lib.ExecPlandexCommandParams{ SessionId: sessionId, }) if err != nil { color.New(term.ColorHiRed).Printf("Error executing command: %v\n", err) } fmt.Println() if preservedBuffer != "" { p.InsertTextMoveCursor(preservedBuffer, true) } if strings.HasPrefix(matchedCmd, "set-auto") || strings.HasPrefix(matchedCmd, "set-config") { term.StartSpinner("") setReplConfig() term.StopSpinner() } return execWithInputResult{shouldReturn: true} } } return execWithInputResult{ condensedInput: condensedInput, trimmedInput: trimmedInput, } } func findSimilarCommands(input string, commands []string) []string { input = strings.TrimSpace(input) input = strings.ToLower(input) input = strings.Trim(input, "/") // Get ranked matches ranks := fuzzy.RankFind(input, commands) // Filter strictly by distance var filtered []string for _, rank := range ranks { // include if either is a substring of the other if strings.Contains(rank.Target, input) || strings.Contains(input, rank.Target) { filtered = append(filtered, rank.Target) continue } // Normalize threshold based on command length maxLen := len(input) if len(rank.Target) > maxLen { maxLen = len(rank.Target) } threshold := 4 // Base threshold if maxLen < 5 { threshold = 1 // Stricter for very short commands } if rank.Distance <= threshold { filtered = append(filtered, rank.Target) } } return filtered }