Skip to content

gosh: clean up autocomplete, courtesy claude.ai#3487

Open
rminnich wants to merge 1 commit intou-root:mainfrom
rminnich:simplify-gosh-autocomplete
Open

gosh: clean up autocomplete, courtesy claude.ai#3487
rminnich wants to merge 1 commit intou-root:mainfrom
rminnich:simplify-gosh-autocomplete

Conversation

@rminnich
Copy link
Member

The autocomplete with bubbletea leads to lots of display probelms, making it almost unusable, esp. in qemu.

This changes the completer to use liner, which is an older package. It does not appear to be maintained, so if we really want it, I suggest we pull the package into u-root. Liner is very small.

This is an experiment. But we have to do something. Bubbletea is unusable.

The autocomplete with bubbletea leads to lots of display probelms,
making it almost unusable, esp. in qemu.

This changes the completer to use liner, which is an older
package. It does not appear to be maintained, so if we really
want it, I suggest we pull the package into u-root. Liner
is very small.

This is an experiment. But we have to do something. Bubbletea
is unusable.

Signed-off-by: Ronald G. Minnich <rminnich@gmail.com>
@rminnich
Copy link
Member Author

note that it fails the autocomplete test. Consider this a WIP.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the bubbletea-based autocomplete implementation with the liner library to resolve display issues, particularly in qemu environments. The change modifies the interactive shell implementation to use a simpler, more stable input library.

Changes:

  • Switched from bubbline to liner library for interactive input handling
  • Rewrote the runInteractive function with multi-line input buffering
  • Added new helper functions getPathCommands() and completeFiles() for tab completion

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 19 to 21
"github.com/knz/bubbline/complete"
"github.com/knz/bubbline/computil"
"github.com/knz/bubbline/editline"
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These bubbline imports are no longer used after switching to liner. The code now uses liner for interactive input instead of bubbline. These unused imports should be removed.

Copilot uses AI. Check for mistakes.
Comment on lines +220 to 323
// getPathCommands returns a list of all executable commands in PATH
func getPathCommands() []string {
pathEnv := os.Getenv("PATH")
if pathEnv == "" {
return nil
}

paths := filepath.SplitList(pathEnv)
commandSet := make(map[string]bool)

for _, dir := range paths {
entries, err := os.ReadDir(dir)
if err != nil {
continue
default:
}

if line != "" {
if err := input.AddHistory(line); err != nil {
fmt.Fprintf(stdout, "unable to add %s to history: %v\n", line, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}

// Check if executable
info, err := entry.Info()
if err != nil {
continue
}

mode := info.Mode()
if mode&0111 != 0 { // Has any execute bit set
commandSet[entry.Name()] = true
}
}
}

if err := parser.Stmts(strings.NewReader(line), func(stmt *syntax.Stmt) bool {
if parser.Incomplete() {
fmt.Fprintf(stdout, "-> ")
return true
// Convert set to sorted slice
commands := make([]string, 0, len(commandSet))
for cmd := range commandSet {
commands = append(commands, cmd)
}

return commands
}

// completeFiles returns file/directory completions for the given prefix
func completeFiles(prefix string) []string {
// Handle empty prefix - complete in current directory
if prefix == "" {
prefix = "./"
}

// Expand home directory
if strings.HasPrefix(prefix, "~/") {
home, err := os.UserHomeDir()
if err == nil {
prefix = filepath.Join(home, prefix[2:])
}
}

// Get directory and file prefix
dir := filepath.Dir(prefix)
filePrefix := filepath.Base(prefix)

// If prefix ends with /, we want to complete in that directory
if strings.HasSuffix(prefix, "/") {
dir = prefix
filePrefix = ""
}

// Read directory entries
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}

var matches []string
for _, entry := range entries {
name := entry.Name()

// Skip hidden files unless explicitly requested
if !strings.HasPrefix(filePrefix, ".") && strings.HasPrefix(name, ".") {
continue
}

// Check if name matches prefix
if strings.HasPrefix(name, filePrefix) {
// Build full path
var fullPath string
if dir == "." {
fullPath = name
} else {
fullPath = filepath.Join(dir, name)
}

runErr = runner.Run(context.Background(), stmt)
return !runner.Exited()
}); err != nil {
fmt.Fprintf(stderr, "error: %s\n", err.Error())
// Add trailing slash for directories
if entry.IsDir() {
fullPath += "/"
}

matches = append(matches, fullPath)
}
}
exit:
return nil

return matches
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getPathCommands and completeFiles functions duplicate existing functionality already available in completer_common.go (commandCompleter and filepathCompleter). This creates code duplication and maintenance burden. Consider reusing the existing functions instead of reimplementing them.

Copilot uses AI. Check for mistakes.
// Set up completion with both commands and files
line.SetCompleter(func(input string) []string {
// Find the position where we should insert completions
words := strings.Fields(input)
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using strings.Fields to parse shell input is problematic because it splits on all whitespace and doesn't handle quoted strings. For example, 'echo "hello world"' would be split into 3 words instead of 2. This will cause incorrect completion behavior when users have quoted arguments. Consider using a proper shell tokenizer or the syntax parser to determine word boundaries.

Copilot uses AI. Check for mistakes.
Comment on lines +324 to +345

//```
//
//## Key Changes
//
//1. **`getPathCommands()`** - Scans all directories in `$PATH` and finds executable files
//2. **Caches commands** - Only scans PATH once at startup for performance
//3. **Checks execute permissions** - Only includes files with execute bit set
//4. **Deduplicates** - Uses a map to handle commands that appear in multiple PATH directories
//
//## Features
//
//- Completes commands from `$PATH` for the first argument
//- Completes files/directories for subsequent arguments or paths with `/`
//- Fast completion (PATH scanned only once)
//- Works with any command in your PATH
//
//Example:
//```
//$ gre<TAB> → completes to grep, grep-changelog, etc.
//$ /usr/bi<TAB> → completes files in /usr/bin/
//$ ls Do<TAB> → completes to Documents/, Downloads/, etc.
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines of comment markup should not be part of the source code. They appear to be leftover documentation or notes that were accidentally included. Remove lines 325-345 as they are not valid Go code and will likely cause compilation errors.

Suggested change
//```
//
//## Key Changes
//
//1. **`getPathCommands()`** - Scans all directories in `$PATH` and finds executable files
//2. **Caches commands** - Only scans PATH once at startup for performance
//3. **Checks execute permissions** - Only includes files with execute bit set
//4. **Deduplicates** - Uses a map to handle commands that appear in multiple PATH directories
//
//## Features
//
//- Completes commands from `$PATH` for the first argument
//- Completes files/directories for subsequent arguments or paths with `/`
//- Fast completion (PATH scanned only once)
//- Works with any command in your PATH
//
//Example:
//```
//$ gre<TAB> → completes to grep, grep-changelog, etc.
//$ /usr/bi<TAB> → completes files in /usr/bin/
//$ ls Do<TAB> → completes to Documents/, Downloads/, etc.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +127
line.SetCompleter(func(input string) []string {
// Find the position where we should insert completions
words := strings.Fields(input)
if len(words) == 0 {
return nil
}

var runErr error
// Get the last word being typed
lastWord := words[len(words)-1]

// Calculate prefix (everything before the last word)
lastWordStart := strings.LastIndex(input, lastWord)
prefix := ""
if lastWordStart > 0 {
prefix = input[:lastWordStart]
}

var matches []string

// If it's the first word and doesn't contain /, complete with commands from PATH
if len(words) == 1 && !strings.Contains(lastWord, "/") {
for _, cmd := range pathCommands {
if strings.HasPrefix(cmd, lastWord) {
matches = append(matches, prefix+cmd)
}
}
}

// The following code is used to intercept SIGINT signals.
// Calling signal.Ignore wouldn't work as child prcesses inherit this trait.
// We only want to catch SIGINTs that are propagated from a child,
// the child itself should get the signal as per usual.
// Always try file completion for paths
fileMatches := completeFiles(lastWord)
for _, match := range fileMatches {
matches = append(matches, prefix+match)
}

return matches
})
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completion flag is no longer checked or used. The new implementation always sets up a completer regardless of the flag value, breaking the intended behavior where users could disable completion. Either check the flag before calling SetCompleter or remove the flag if it's no longer needed.

Copilot uses AI. Check for mistakes.
Comment on lines +265 to +285
// Handle empty prefix - complete in current directory
if prefix == "" {
prefix = "./"
}

// Expand home directory
if strings.HasPrefix(prefix, "~/") {
home, err := os.UserHomeDir()
if err == nil {
prefix = filepath.Join(home, prefix[2:])
}
}

// Get directory and file prefix
dir := filepath.Dir(prefix)
filePrefix := filepath.Base(prefix)

// If prefix ends with /, we want to complete in that directory
if strings.HasSuffix(prefix, "/") {
dir = prefix
filePrefix = ""
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When prefix is empty, setting it to "./" will cause completions to always include the "./" prefix, which may not match user expectations. For example, if the user types nothing and presses tab, they'll see "./file" instead of just "file". The existing filepathCompleter in completer_common.go handles this case better.

Suggested change
// Handle empty prefix - complete in current directory
if prefix == "" {
prefix = "./"
}
// Expand home directory
if strings.HasPrefix(prefix, "~/") {
home, err := os.UserHomeDir()
if err == nil {
prefix = filepath.Join(home, prefix[2:])
}
}
// Get directory and file prefix
dir := filepath.Dir(prefix)
filePrefix := filepath.Base(prefix)
// If prefix ends with /, we want to complete in that directory
if strings.HasSuffix(prefix, "/") {
dir = prefix
filePrefix = ""
var dir string
var filePrefix string
if prefix == "" {
// Empty prefix: complete in current directory with no initial filename prefix.
dir = "."
filePrefix = ""
} else {
// Expand home directory
if strings.HasPrefix(prefix, "~/") {
home, err := os.UserHomeDir()
if err == nil {
prefix = filepath.Join(home, prefix[2:])
}
}
// Get directory and file prefix
dir = filepath.Dir(prefix)
filePrefix = filepath.Base(prefix)
// If prefix ends with /, we want to complete in that directory
if strings.HasSuffix(prefix, "/") {
dir = prefix
filePrefix = ""
}

Copilot uses AI. Check for mistakes.
lastWord := words[len(words)-1]

// Calculate prefix (everything before the last word)
lastWordStart := strings.LastIndex(input, lastWord)
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using strings.LastIndex to find the position of the last word is unreliable when the same word appears multiple times in the input. For example, in "echo test test", when completing the second "test", this would find the first occurrence instead. This will cause the prefix to be calculated incorrectly.

Copilot uses AI. Check for mistakes.
}
// Set up liner
line := liner.NewLiner()
defer line.Close()
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

History persistence is not implemented with liner. The old bubbline code loaded and auto-saved history using HistFile, but this functionality was removed. Users will lose their command history between sessions. Consider adding line.ReadHistory(HistFile) after creating the liner instance and line.WriteHistory(HistFile) before returning or in a defer statement.

Suggested change
defer line.Close()
_ = line.ReadHistory(HistFile)
defer func() {
_ = line.WriteHistory(HistFile)
line.Close()
}()

Copilot uses AI. Check for mistakes.
// Find the position where we should insert completions
words := strings.Fields(input)
if len(words) == 0 {
return nil
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the input is empty or contains only whitespace, strings.Fields returns an empty slice, but the user might want to complete commands from PATH. The current implementation returns nil for empty input. Consider providing command completions even when input is empty to improve user experience.

Suggested change
return nil
// When the input is empty or whitespace, offer command completions from PATH.
return append([]string(nil), pathCommands...)

Copilot uses AI. Check for mistakes.
Comment on lines +230 to +252
for _, dir := range paths {
entries, err := os.ReadDir(dir)
if err != nil {
continue
default:
}

if line != "" {
if err := input.AddHistory(line); err != nil {
fmt.Fprintf(stdout, "unable to add %s to history: %v\n", line, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}

// Check if executable
info, err := entry.Info()
if err != nil {
continue
}

mode := info.Mode()
if mode&0111 != 0 { // Has any execute bit set
commandSet[entry.Name()] = true
}
}
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling os.ReadDir for every directory in PATH and then calling entry.Info() for each file is inefficient compared to the existing commandCompleter implementation which uses filepath.WalkDir. Additionally, this function doesn't handle subdirectories, meaning it won't find executables in PATH directories that contain subdirectories. The existing commandCompleter implementation is more robust.

Copilot uses AI. Check for mistakes.
@rminnich
Copy link
Member Author

I'm enjoying watching copilot critique Claude's code :-)

@brho
Copy link

brho commented Jan 18, 2026

I'm enjoying watching copilot critique Claude's code :-)

see if claude has any good responses to copilot's critiques!

@rminnich
Copy link
Member Author

rminnich commented Jan 19, 2026

before I go any further I want to see how people feel about removing bubbline. Having tried to use it, and having dealt with complaints from users, I see no downside.

@rminnich
Copy link
Member Author

git@github.com:reeflective/readline.git (looks simpler and hence possibly preferred)
git@github.com:chzyer/readline.git

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Awaiting reviewer Waiting for a reviewer.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants