From 0d8bbdcd259b290ce918373139a022660357b00b Mon Sep 17 00:00:00 2001 From: Jason Kim Date: Thu, 4 Dec 2025 13:28:19 -0700 Subject: [PATCH 1/2] feat: enable auto-wrapping for long CLI queries by switching readline library Previously, long queries in the CLI would scroll horizontally, causing the beginning of the text to disappear and making it difficult to review or edit long inputs. This commit switches the underlying readline library from 'github.com/nyaosorg/go-readline-ny' to 'github.com/ergochat/readline'. The new library supports automatic line wrapping by default and handles prompt width calculations correctly, fixing issues with cursor positioning and arrow key navigation on wrapped lines. Improvements: - Long queries now wrap to the next line instead of scrolling horizontally. - Navigation with arrow keys, Ctrl+A (start of line), and Ctrl+E (end of line) works correctly on wrapped lines. - Character deletion behaves as expected across multiple lines. Key changes: - Replaced 'go-readline-ny' with 'ergochat/readline'. - Implemented 'TmuxAICompleter' in 'internal/chat_completer.go' to maintain tab completion functionality. - Updated 'internal/chat.go' to initialize and use the new readline configuration. --- go.mod | 7 +- go.sum | 14 +--- internal/chat.go | 149 +++++++------------------------------ internal/chat_completer.go | 75 +++++++++++++++++++ 4 files changed, 105 insertions(+), 140 deletions(-) create mode 100644 internal/chat_completer.go 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..a36e6bf 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 @@ -39,68 +36,49 @@ 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 editor - editor := &readline.Editor{ - PromptWriter: func(w io.Writer) (int, error) { - return io.WriteString(w, c.manager.GetPrompt()) - }, - History: history, - HistoryCycling: true, + // 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 } - - // 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) - - if err == readline.CtrlC { - // Ctrl+C pressed, clear the line and continue - continue + // Update prompt (in case state changed) + rl.SetPrompt(c.manager.GetPrompt()) + + 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 @@ -157,76 +135,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_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) +} From 05e88edd3fab93c45160e4d7801dfa30fbdb94fb Mon Sep 17 00:00:00 2001 From: Jason Kim Date: Mon, 8 Dec 2025 15:21:46 -0700 Subject: [PATCH 2/2] feat: add /paste command for multiline input support This commit introduces a new '/paste' command to the CLI interface, addressing the limitation where pasting multiline text (e.g., code blocks) into the readline prompt would trigger immediate submission for each line. Changes: - Added 'pasteMode' state to CLIInterface to buffer input lines. - Implemented '/paste' command to enter paste mode (prompt changes to '... '). - Implemented '/end' command to submit the buffered content as a single message. - Implemented '/cancel' command to exit paste mode without submitting. - Updated help messages and command lists to include '/paste'. --- internal/chat.go | 47 ++++++++++++++++++++++++++++++++++------ internal/chat_command.go | 2 ++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/internal/chat.go b/internal/chat.go index a36e6bf..033a1bf 100644 --- a/internal/chat.go +++ b/internal/chat.go @@ -23,6 +23,8 @@ type ChatMessage struct { type CLIInterface struct { manager *Manager initMessage string + pasteMode bool + pasteBuffer strings.Builder } func NewCLIInterface(manager *Manager) *CLIInterface { @@ -60,7 +62,11 @@ func (c *CLIInterface) Start(initMessage string) error { for { // Update prompt (in case state changed) - rl.SetPrompt(c.manager.GetPrompt()) + if c.pasteMode { + rl.SetPrompt("... ") + } else { + rl.SetPrompt(c.manager.GetPrompt()) + } line, err := rl.Readline() if err == readline.ErrInterrupt { @@ -80,11 +86,13 @@ func (c *CLIInterface) Start(initMessage string) error { // 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) @@ -94,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 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",