diff --git a/go.mod b/go.mod index 4e7c0b4..ab6af6b 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/alecthomas/chroma v0.10.0 github.com/briandowns/spinner v1.23.2 github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 + github.com/ergochat/readline v0.1.3 github.com/fatih/color v1.18.0 - github.com/nyaosorg/go-readline-ny v1.13.0 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -18,7 +18,6 @@ require ( ) require ( - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -26,10 +25,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/mattn/go-tty v0.0.7 // indirect - github.com/nyaosorg/go-box/v3 v3.0.0 // indirect - github.com/nyaosorg/go-ttyadapter v0.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect diff --git a/go.sum b/go.sum index 942d8c0..220ed4d 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbf github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -14,6 +12,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= +github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo= +github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -34,16 +34,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= -github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= -github.com/nyaosorg/go-box/v3 v3.0.0 h1:W5qfScEkKBoD68gbP/lwfWlvcTRB0rwXkhL+9iC62xI= -github.com/nyaosorg/go-box/v3 v3.0.0/go.mod h1:70GsE9mIh7JKVCxt71q3jEijO6C9YJmOZqWpPa9w+GY= -github.com/nyaosorg/go-readline-ny v1.13.0 h1:3ifu0FQsswdB9N5vcph7lVzQvu20kVt6pMt1JuSz45o= -github.com/nyaosorg/go-readline-ny v1.13.0/go.mod h1:pFXLTklUi8JVi6gI3HdnA3Wak/7cl+kx2q8kIIiAf/c= -github.com/nyaosorg/go-ttyadapter v0.1.0 h1:3U3ytc35SOdkrn15rHLG36ozkmqBeGqhQgNufoep1AI= -github.com/nyaosorg/go-ttyadapter v0.1.0/go.mod h1:w6ySb/Y8rpr0uIju4vN/TMRHC/6ayabORHmEVs6d/qE= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/internal/chat.go b/internal/chat.go index 342039c..033a1bf 100644 --- a/internal/chat.go +++ b/internal/chat.go @@ -10,10 +10,7 @@ import ( "time" "github.com/alvinunreal/tmuxai/config" - "github.com/nyaosorg/go-readline-ny" - "github.com/nyaosorg/go-readline-ny/completion" - "github.com/nyaosorg/go-readline-ny/keys" - "github.com/nyaosorg/go-readline-ny/simplehistory" + "github.com/ergochat/readline" ) // Message represents a chat message @@ -26,6 +23,8 @@ type ChatMessage struct { type CLIInterface struct { manager *Manager initMessage string + pasteMode bool + pasteBuffer strings.Builder } func NewCLIInterface(manager *Manager) *CLIInterface { @@ -39,74 +38,61 @@ func NewCLIInterface(manager *Manager) *CLIInterface { func (c *CLIInterface) Start(initMessage string) error { c.printWelcomeMessage() - // Initialize history - history := simplehistory.New() historyFilePath := config.GetConfigFilePath("history") - // Load history from file if it exists - if historyData, err := os.ReadFile(historyFilePath); err == nil { - for _, line := range strings.Split(string(historyData), "\n") { - if line = strings.TrimSpace(line); line != "" { - history.Add(line) - } - } + // Initialize readline + rl, err := readline.NewEx(&readline.Config{ + Prompt: c.manager.GetPrompt(), + HistoryFile: historyFilePath, + AutoComplete: NewTmuxAICompleter(c.manager), + InterruptPrompt: "^C", + EOFPrompt: "exit", + HistorySearchFold: true, + }) + if err != nil { + return err } - - // Initialize editor - editor := &readline.Editor{ - PromptWriter: func(w io.Writer) (int, error) { - return io.WriteString(w, c.manager.GetPrompt()) - }, - History: history, - HistoryCycling: true, - } - - // Bind TAB key to completion - editor.BindKey(keys.CtrlI, c.newCompleter()) + defer rl.Close() + rl.CaptureExitSignal() if initMessage != "" { fmt.Printf("%s%s\n", c.manager.GetPrompt(), initMessage) c.processInput(initMessage) } - ctx := context.Background() - for { - line, err := editor.ReadLine(ctx) + // Update prompt (in case state changed) + if c.pasteMode { + rl.SetPrompt("... ") + } else { + rl.SetPrompt(c.manager.GetPrompt()) + } - if err == readline.CtrlC { - // Ctrl+C pressed, clear the line and continue - continue + line, err := rl.Readline() + if err == readline.ErrInterrupt { + if len(line) == 0 { + continue + } else { + continue + } } else if err == io.EOF { - // Ctrl+D pressed, exit return nil } else if err != nil { return err } - // Save history - if line != "" { - history.Add(line) - - // Build history data by iterating through all entries - historyLines := make([]string, 0, history.Len()) - for i := 0; i < history.Len(); i++ { - historyLines = append(historyLines, history.At(i)) - } - historyData := strings.Join(historyLines, "\n") - _ = os.WriteFile(historyFilePath, []byte(historyData), 0644) - } - - // Process the input (preserving multiline content) - input := line // Keep the original line including newlines + // Process the input + input := line - // Check for exit/quit commands (only if it's the entire line content) + // Check for exit/quit commands trimmed := strings.TrimSpace(input) - if trimmed == "exit" || trimmed == "quit" { - return nil - } - if trimmed == "" { - continue + if !c.pasteMode { + if trimmed == "exit" || trimmed == "quit" { + return nil + } + if trimmed == "" { + continue + } } c.processInput(input) @@ -116,11 +102,36 @@ func (c *CLIInterface) Start(initMessage string) error { // printWelcomeMessage prints a welcome message func (c *CLIInterface) printWelcomeMessage() { fmt.Println() - fmt.Println("Type '/help' for a list of commands, '/exit' to quit") + fmt.Println("Type '/help' for a list of commands, '/paste' to enter paste mode, '/exit' to quit") fmt.Println() } func (c *CLIInterface) processInput(input string) { + if input == "/paste" { + c.pasteMode = true + c.pasteBuffer.Reset() + fmt.Println("Entering paste mode. Type '/end' to submit, or '/cancel' to abort.") + return + } + + if c.pasteMode { + trimmed := strings.TrimSpace(input) + if trimmed == "/end" { + c.pasteMode = false + input = c.pasteBuffer.String() + c.pasteBuffer.Reset() + fmt.Println("Processing pasted content...") + } else if trimmed == "/cancel" { + c.pasteMode = false + c.pasteBuffer.Reset() + fmt.Println("Paste mode cancelled.") + return + } else { + c.pasteBuffer.WriteString(input + "\n") + return + } + } + if c.manager.IsMessageSubcommand(input) { c.manager.ProcessSubCommand(input) return @@ -157,76 +168,3 @@ func (c *CLIInterface) processInput(input string) { signal.Stop(sigChan) } - -// newCompleter creates a completion handler for command completion -func (c *CLIInterface) newCompleter() *completion.CmdCompletionOrList2 { - return &completion.CmdCompletionOrList2{ - Delimiter: " ", - Postfix: " ", - Candidates: func(field []string) (forComp []string, forList []string) { - // Handle top-level commands - if len(field) == 0 || (len(field) == 1 && !strings.HasSuffix(field[0], " ")) { - return commands, commands - } - - // Handle /config subcommands - if len(field) > 0 && field[0] == "/config" { - if len(field) == 1 || (len(field) == 2 && !strings.HasSuffix(field[1], " ")) { - return []string{"set", "get"}, []string{"set", "get"} - } else if len(field) == 2 || (len(field) == 3 && !strings.HasSuffix(field[2], " ")) { - return AllowedConfigKeys, AllowedConfigKeys - } - } - - // Handle /prepare subcommands - if len(field) > 0 && field[0] == "/prepare" { - if len(field) == 1 || (len(field) == 2 && !strings.HasSuffix(field[1], " ")) { - return []string{"bash", "zsh", "fish"}, []string{"bash", "zsh", "fish"} - } - } - - // Handle /kb subcommands - if len(field) > 0 && field[0] == "/kb" { - if len(field) == 1 || (len(field) == 2 && !strings.HasSuffix(field[1], " ")) { - return []string{"list", "load", "unload"}, []string{"list", "load", "unload"} - } else if (len(field) == 2 && field[1] == "load") || (len(field) >= 3 && field[1] == "load") { - // Get available knowledge bases for completion - kbs, err := c.manager.listKBs() - if err != nil { - return nil, nil - } - // Disable autocompletion when there's only one KB, bug with readline - if len(kbs) == 1 { - return nil, nil - } - return kbs, kbs - } else if (len(field) == 2 && field[1] == "unload") || (len(field) >= 3 && field[1] == "unload") { - // For unload, show loaded knowledge bases and --all option - var kbNames []string - for name := range c.manager.LoadedKBs { - kbNames = append(kbNames, name) - } - kbNames = append(kbNames, "--all") - return kbNames, kbNames - } - } - - // Handle /model subcommands - if len(field) > 0 && field[0] == "/model" { - if len(field) == 1 || (len(field) == 2 && !strings.HasSuffix(field[1], " ")) { - // Return available models for completion - availableModels := c.manager.GetAvailableModels() - if len(availableModels) == 0 { - return nil, nil - } - // Disable autocompletion when there's only one model, bug with readline - if len(availableModels) == 1 { - return nil, nil - } - return availableModels, availableModels - } - } - return nil, nil - }, - } -} diff --git a/internal/chat_command.go b/internal/chat_command.go index 079ef0c..413aebc 100644 --- a/internal/chat_command.go +++ b/internal/chat_command.go @@ -15,6 +15,7 @@ const helpMessage = `Available commands: - /info: Display system information - /clear: Clear the chat history - /reset: Reset the chat history +- /paste: Enter paste mode for multiline input - /prepare: Prepare the pane for TmuxAI automation - /watch : Start watch mode - /squash: Summarize the chat history @@ -30,6 +31,7 @@ var commands = []string{ "/help", "/clear", "/reset", + "/paste", "/exit", "/info", "/watch", diff --git a/internal/chat_completer.go b/internal/chat_completer.go new file mode 100644 index 0000000..f719883 --- /dev/null +++ b/internal/chat_completer.go @@ -0,0 +1,75 @@ +package internal + +import "strings" + +type TmuxAICompleter struct { + manager *Manager +} + +func NewTmuxAICompleter(manager *Manager) *TmuxAICompleter { + return &TmuxAICompleter{manager: manager} +} + +func (c *TmuxAICompleter) Do(line []rune, pos int) (newLine [][]rune, length int) { + input := string(line[:pos]) + fields := strings.Fields(input) + + // If the cursor is after a space, we are starting a new field + if len(line) > 0 && line[pos-1] == ' ' { + fields = append(fields, "") + } + + var candidates []string + + // Handle top-level commands + if len(fields) == 0 || (len(fields) == 1 && !strings.HasSuffix(input, " ")) { + candidates = commands + } else if len(fields) > 0 { + switch fields[0] { + case "/config": + if len(fields) == 2 { + candidates = []string{"set", "get"} + } else if len(fields) == 3 { + candidates = AllowedConfigKeys + } + case "/prepare": + if len(fields) == 2 { + candidates = []string{"bash", "zsh", "fish"} + } + case "/kb": + if len(fields) == 2 { + candidates = []string{"list", "load", "unload"} + } else if len(fields) == 3 { + if fields[1] == "load" { + kbs, err := c.manager.listKBs() + if err == nil { + candidates = kbs + } + } else if fields[1] == "unload" { + for name := range c.manager.LoadedKBs { + candidates = append(candidates, name) + } + candidates = append(candidates, "--all") + } + } + case "/model": + if len(fields) == 2 { + candidates = c.manager.GetAvailableModels() + } + } + } + + // Filter candidates based on the current word + var currentWord string + if len(fields) > 0 { + currentWord = fields[len(fields)-1] + } + + for _, candidate := range candidates { + if strings.HasPrefix(candidate, currentWord) { + newLine = append(newLine, []rune(candidate[len(currentWord):])) + } + } + + return newLine, len(currentWord) +}