prompt

September 2, 2026 · View on GitHub

Go Reference MultiPlatformUnitTest Coverage

logo

prompt is a terminal prompt library for Go for building interactive command-line interfaces. It is a maintained replacement for the archived c-bata/go-prompt, keeping the same core idea, a read loop with completion and history, while running on Linux, macOS, and Windows.

demo

The animation is example/demo, a toy SQL shell built on this library, driven by demo.tape. Run it yourself with go run ./example/demo.

Features

  • Tab completion, including fuzzy matching, with customizable suggestions and completer-chosen replacement spans
  • Command history with arrow-key navigation, persistence, and reverse search (Ctrl+R)
  • Emacs-style key bindings
  • Multi-line input with cursor navigation
  • Built-in color themes
  • A small API using the functional options pattern
  • Runs on Linux, macOS, and Windows

Installation

go get github.com/nao1215/prompt

Building needs Go 1.24 or later.

Quick start

Basic usage

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "github.com/nao1215/prompt"
)

func main() {
    p, err := prompt.New("$ ")
    if err != nil {
        log.Fatal(err)
    }
    defer p.Close()

    for {
        input, err := p.Run(context.Background())
        if err != nil {
            if errors.Is(err, prompt.ErrEOF) {
                fmt.Println("Goodbye!")
                break
            }
            log.Printf("Error: %v\n", err)
            continue
        }

        if input == "exit" {
            break
        }
        fmt.Printf("You entered: %s\n", input)
    }
}

With auto-completion

package main

import (
    "context"
    "errors"
    "log"
    "github.com/nao1215/prompt"
)

func completer(d prompt.Document) []prompt.Suggestion {
    return []prompt.Suggestion{
        {Text: "help", Description: "Show help message"},
        {Text: "users", Description: "List all users"},
        {Text: "groups", Description: "List all groups"},
        {Text: "exit", Description: "Exit the program"},
    }
}

func main() {
    p, err := prompt.New("myapp> ",
        prompt.WithCompleter(completer),
        prompt.WithTheme(prompt.ThemeNightOwl),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer p.Close()

    for {
        input, err := p.Run(context.Background())
        if err != nil {
            if errors.Is(err, prompt.ErrEOF) {
                break
            }
            continue
        }

        if input == "exit" {
            break
        }
        // Handle commands...
    }
}

With history and a context deadline

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "time"
    "github.com/nao1215/prompt"
)

func main() {
    p, err := prompt.New(">>> ",
        prompt.WithMemoryHistory(100),
        prompt.WithTheme(prompt.ThemeDracula),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer p.Close()

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    input, err := p.Run(ctx)
    if errors.Is(err, context.DeadlineExceeded) {
        fmt.Println("Timeout reached")
        return
    }

    fmt.Printf("Input: %s\n", input)
}

SQL-like interactive shell

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "strings"
    "github.com/nao1215/prompt"
)

func sqlCompleter(d prompt.Document) []prompt.Suggestion {
    keywords := []string{
        "SELECT", "FROM", "WHERE", "INSERT", "UPDATE",
        "DELETE", "CREATE TABLE", "DROP TABLE",
    }

    suggestions := []prompt.Suggestion{}
    input := strings.ToUpper(d.WordBeforeCursor())

    for _, keyword := range keywords {
        if strings.HasPrefix(keyword, input) {
            suggestions = append(suggestions, prompt.Suggestion{
                Text: keyword,
                Description: "SQL keyword",
            })
        }
    }
    return suggestions
}

func main() {
    p, err := prompt.New("sql> ",
        prompt.WithCompleter(sqlCompleter),
        prompt.WithMemoryHistory(50),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer p.Close()

    for {
        query, err := p.Run(context.Background())
        if err != nil {
            if errors.Is(err, prompt.ErrEOF) {
                break
            }
            continue
        }

        if query == "exit" || query == "quit" {
            break
        }

        if strings.TrimSpace(query) != "" {
            fmt.Printf("Executing: %s\n", query)
            // Execute SQL query here...
        }
    }
}

Advanced usage

Fuzzy completion

commands := []string{
    "git status", "git commit", "git push", "git pull",
    "docker run", "docker build", "docker ps",
    "kubectl get", "kubectl apply", "kubectl delete",
}

fuzzyCompleter := prompt.NewFuzzyCompleter(commands)

p, err := prompt.New("$ ",
    prompt.WithCompleter(fuzzyCompleter),
)

NewFuzzyCompleter matches the input before the cursor rather than the word before it, ignores case, and accepts a subsequence, so git st finds git status, GIT PU finds git push, and dckrbld finds docker build. Accepting a candidate replaces the input before the cursor.

Completing a span of your own choosing

By default the prompt decides what a suggestion replaces: it takes the word before the cursor and keeps a suggestion only when the word is a case-sensitive prefix of it. A completer that matches by another rule can name the span itself, and the prompt then applies that span literally and skips its own filter.

Replace is counted in runes, the same unit as Document.CursorPosition.

func completer(d prompt.Document) []prompt.Suggestion {
    word := d.WordBeforeCursor()
    start := d.CursorPosition - len([]rune(word))

    var out []prompt.Suggestion
    for _, kw := range []string{"SELECT", "INSERT", "UPDATE"} {
        // Match case-insensitively, which the built-in filter cannot do.
        if strings.HasPrefix(strings.ToLower(kw), strings.ToLower(word)) {
            out = append(out, prompt.Suggestion{
                Text:    kw,
                Replace: &prompt.Range{Start: start, End: d.CursorPosition},
            })
        }
    }
    return out
}

Typing sel and pressing Tab now yields SELECT. Leave Replace nil to keep the word-based behavior.

Custom key bindings

keyMap := prompt.NewDefaultKeyMap()
// Reach the history from a multiline entry, where the arrow keys move the
// cursor between its lines.
keyMap.Bind('\x10', prompt.ActionHistoryUp)   // Ctrl+P
keyMap.Bind('\x0E', prompt.ActionHistoryDown) // Ctrl+N

p, err := prompt.New("$ ",
    prompt.WithKeyMap(keyMap),
)

Persistent history

p, err := prompt.New("$ ",
    prompt.WithFileHistory("~/.myapp_history", 1000),
)

No history at all

Run remembers every line it returns, so a one-off question whose answer should not be kept — a password, a token — asks for no history:

p, err := prompt.New("password: ",
    prompt.WithoutHistory(),
)

Nothing is remembered, the arrow keys and Ctrl+R have nothing to walk, and no file is read or written.

The file is loaded when the prompt is built and written when an entry is added. A save writes at most one megabyte of entries, and a file that would lose entries to that limit is moved aside first, as file.1 through file.3.

Multi-line submit control

In multiline mode, WithIsComplete decides whether Enter submits the buffer or starts a new line. It receives the whole buffer and returns true when the input is ready to run, so an app can buffer multi-line input such as SQL until a trailing ;. Backslash continuation and bracketed paste are unaffected.

isComplete := func(input string) bool {
    return strings.HasSuffix(strings.TrimSpace(input), ";")
}

p, err := prompt.New("sql> ",
    prompt.WithMultiline(),
    prompt.WithIsComplete(isComplete),
)

Pair it with WithContinuationPrefix so a buffered line says it is waiting. Without one, a statement IsComplete declined leaves the cursor on a bare line with nothing in front of it, which is indistinguishable from a hung program:

p, err := prompt.New("sql> ",
    prompt.WithMultiline(),
    prompt.WithIsComplete(isComplete),
    prompt.WithContinuationPrefix(" ..> "),
)
sql> SELECT id,
 ..> name FROM users;

The prefix is drawn in the prompt's color and counted when positioning the cursor and measuring how many rows the input occupies, so editing a continuation line lands where the character is. It never appears in the returned input.

An entry taller than the terminal is drawn as the rows around the cursor that the terminal has room for, redrawn in place. The rows outside that window are left undrawn rather than drawn and scrolled away, because what scrolls off the top of the screen is the application's output rather than the prompt's, and the window moves only as far as the cursor makes it. A line ends at the foot of the entry whichever of its lines the cursor was on, so what the application prints next starts below the entry rather than on top of it.

Persistent raw mode (REPL loops)

A REPL that calls Run once per line normally enters raw mode at the start of each call and restores it when the call returns. Between one line's restore and the next line's re-acquisition the read loop is not consuming input, so bytes that a fast or automated driver (a pipe or pseudo-terminal) sends right after the prompt is re-rendered can be lost, making scripted sessions hang intermittently.

WithPersistentRawMode keeps the terminal in raw mode across consecutive Run calls, closing that window and making input deterministic regardless of timing or load. Raw mode is acquired once on the first Run and released once — by Close or when input reaches EOF. Ctrl+C does not release it, because it ends the line rather than the session and the next Run continues where it left off. Because the terminal stays in raw mode between calls, print your own output between prompts with \r\n rather than \n.

p, err := prompt.New("$ ",
    prompt.WithPersistentRawMode(),
)
if err != nil {
    log.Fatal(err)
}
defer p.Close()

for {
    line, err := p.Run(context.Background())
    if errors.Is(err, prompt.ErrEOF) || errors.Is(err, prompt.ErrInterrupted) {
        break
    }
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("you typed: %s\r\n", line) // note the \r\n
}

Indenting continuation lines

WithAutoIndent decides what each new line opens with. It is called with the input up to where the line breaks, and what it returns is inserted at the start of the new line:

p, err := prompt.New("sql> ",
    prompt.WithMultiline(),
    prompt.WithIsComplete(func(in string) bool { return strings.HasSuffix(in, ";") }),
    prompt.WithContinuationPrefix("...> "),
    prompt.WithAutoIndent(func(before string) string {
        // Keep the indentation of the line being continued.
        line := before[strings.LastIndex(before, "\n")+1:]
        return line[:len(line)-len(strings.TrimLeft(line, " \t"))]
### Coloring the input

`WithHighlighter` is given the whole input and returns the runs to draw in a
color of their own, as rune offsets into that input:

```go
p, err := prompt.New("sql> ",
    prompt.WithHighlighter(func(input string) []prompt.StyleSpan {
        var spans []prompt.StyleSpan
        for _, kw := range keywordRuns(input) { // your lexer
            spans = append(spans, prompt.StyleSpan{
                Start: kw.start, End: kw.end,
                Color: prompt.Color{R: 198, G: 120, B: 221, Bold: true},
            })
        }
        return spans
    }),
)

Everything no run covers keeps the scheme's input color, and a run given the zero prompt.Color keeps it too, which is how a highlighter says "leave this one alone". The highlighter decides colors and nothing else: the input is drawn exactly as it is, and the prompt measures its layout from that text, so highlighting cannot move the cursor or wrap a line early. Runs that overlap, run backwards, or reach past either end of the input are normalized rather than rejected — getting a color wrong must not cost the line being typed. It is called on every render, so it should be cheap over a line's worth of text.

Handing the terminal to another program

A prompt owns the terminal while it lives. To run an editor, a pager, or any other program that draws on the terminal, close the prompt and open a new one afterwards:

if err := p.Close(); err != nil {
    return err
}

cmd := exec.Command(os.Getenv("EDITOR"), path)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
    return err
}

p, err := prompt.New("$ ", opts...) // a fresh session takes the terminal back
if err != nil {
    return err
}

On Unix, Close ends the goroutine reading the terminal before it returns, so the child process and the next prompt get the input typed into them. On Windows input is read through go-tty, whose read cannot be interrupted, so Close does not wait for it: a prompt opened there while an earlier reader is still blocked may lose keystrokes to it.

Interrupting work between prompts

Run returns as soon as a line is submitted, so while the application executes that line nothing is reading the terminal. Ctrl+C cannot reach the running work on its own. While the prompt holds the terminal it is a byte that waits in the input buffer and is read as part of the next line once the work is over; once the prompt has given the terminal back — which Run does when it returns, unless the session asked for persistent raw mode — the terminal turns it into a SIGINT that kills the application in the middle of that work.

WatchInterrupt watches for the byte and the signal during that gap, and returns a context canceled when the key arrives, however the terminal delivers it:

for {
    line, err := p.Run(context.Background())
    if errors.Is(err, prompt.ErrEOF) {
        break // Ctrl+D at an empty prompt ends the session
    }
    if errors.Is(err, prompt.ErrInterrupted) {
        continue // Ctrl+C discards the line being typed
    }
    if err != nil {
        return err
    }

    ctx, stop := p.WatchInterrupt(context.Background())
    err = runQuery(ctx, line) // a long query, an import, anything slow
    stop()

    if errors.Is(err, context.Canceled) {
        fmt.Print("canceled\r\n")
        continue
    }
    if err != nil {
        fmt.Printf("%v\r\n", err)
    }
}

Everything else typed while the work runs belongs to the next line: it is held and delivered to the following Run in the order it was typed, so typing ahead keeps working. Do not call Run while a watch is active — a line editor and a watcher cannot both own one terminal.

While the watch is active, Ctrl+C no longer kills the application: it cancels the work instead, and stop restores the usual behavior. That holds for every interrupt the watch sees, not only the first — the work has been told to stop and has not finished stopping, which is exactly when the key gets pressed again. An interrupt sent any other way, such as kill -INT from another terminal, cancels the work too: nothing tells it apart from the key.

Watches may be nested. There is one watcher for the prompt however many are active, and an interrupt cancels all of them: the work being watched is nested, so canceling the inner half alone would leave the outer half running with nothing left to stop it.

Key bindings

KeyAction
EnterSubmit input
Ctrl+CDiscard the current line and return ErrInterrupted
Ctrl+DEOF when buffer is empty
↑/↓Navigate history (or lines in multi-line mode)
←/→Move cursor
Ctrl+A / HomeMove to beginning of line
Ctrl+E / EndMove to end of line
Ctrl+KDelete from cursor to end of line
Ctrl+UDelete the line the cursor is on, which on an entry of one line is the whole of it
Ctrl+WDelete word backwards
Ctrl+RReverse history search; Tab and ↑/↓ move through the matches, Enter accepts the one the search names, Esc cancels
TabAuto-completion
BackspaceDelete character backwards
DeleteDelete character forwards
Ctrl+LClear the screen and redraw the prompt at the top of it, keeping the scrollback
Ctrl+←/→Move by word boundaries
EscClose the completion popup

A completion menu stands for the word before the cursor, so it lasts only as long as that word: editing the line, discarding it with Ctrl+U, or moving the cursor off the word ends the completion, and the next Tab asks the completer again.

The menu lists at most ten candidates, and fewer when ten would not fit: it takes only the rows the terminal has left under the line being typed, so that line stays on screen. Up and Down scroll the list when it is longer than the window. A candidate wider than the terminal wraps onto more than one row, so fewer of them fit. On a terminal with no room at all -- an input that already fills the screen -- no menu is drawn. A single match still completes on Tab; with several matches, the next Tab accepts a candidate you cannot see.

Color themes

A prompt that names no theme draws the line being typed and the completion menu's candidates in the terminal's own foreground, and colors only what carries meaning wherever it lands: the prefix, the selected candidate, a description. The named themes below choose a foreground, so they say which background they are for.

The zero prompt.Color is that same "the terminal's own color", which is what a field left out of a ColorScheme literal means. Black is prompt.Color{B: 1}.

// Available themes
prompt.ThemeDefault
prompt.ThemeDark
prompt.ThemeLight
prompt.ThemeAccessible
prompt.ThemeSolarizedDark
prompt.ThemeVSCode
prompt.ThemeNightOwl
prompt.ThemeDracula
prompt.ThemeMonokai

// Usage
p, err := prompt.New("$ ",
    prompt.WithTheme(prompt.ThemeDracula),
)

A scheme of your own decides foreground colors: the prefix, the input, and the completion menu. The prompt writes over the terminal's own background and leaves the terminal's own cursor alone, so there is no field for either.

Examples

The example directory has complete programs:

Notes

Thread safety

Drive one prompt from one goroutine: do not call Run concurrently, and do not edit a prompt's state from a second goroutine. Use a separate instance per goroutine if you need concurrency.

Ending a session from another goroutine is the exception, because a prompt waiting for a key cannot end itself: canceling the context passed to Run and calling Close both end that wait, and the Run returns context.Canceled and ErrEOF respectively. A Run on a prompt that is already closed returns ErrEOF without touching the terminal.

Error handling

Run returns specific errors:

  • prompt.ErrEOF: Ctrl+D on an empty buffer, the input reaching its end, or a Run on a prompt that is already closed. It matches io.EOF as well as itself
  • prompt.ErrInterrupted: Ctrl+C
  • context.DeadlineExceeded: the context deadline passed
  • context.Canceled: the context was canceled

Contributing

Contributions are welcome; see the Contributing Guide. A GitHub Star also helps and motivates development. Development needs Go 1.24 or later and golangci-lint, with tests run on Linux, macOS, and Windows.

License

This project is licensed under the MIT License. See LICENSE for details.