prompt
September 2, 2026 · View on GitHub

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.

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
| Key | Action |
|---|---|
| Enter | Submit input |
| Ctrl+C | Discard the current line and return ErrInterrupted |
| Ctrl+D | EOF when buffer is empty |
| ↑/↓ | Navigate history (or lines in multi-line mode) |
| ←/→ | Move cursor |
| Ctrl+A / Home | Move to beginning of line |
| Ctrl+E / End | Move to end of line |
| Ctrl+K | Delete from cursor to end of line |
| Ctrl+U | Delete the line the cursor is on, which on an entry of one line is the whole of it |
| Ctrl+W | Delete word backwards |
| Ctrl+R | Reverse history search; Tab and ↑/↓ move through the matches, Enter accepts the one the search names, Esc cancels |
| Tab | Auto-completion |
| Backspace | Delete character backwards |
| Delete | Delete character forwards |
| Ctrl+L | Clear the screen and redraw the prompt at the top of it, keeping the scrollback |
| Ctrl+←/→ | Move by word boundaries |
| Esc | Close 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:
- Basic usage - a simple prompt
- Auto-completion - tab completion with suggestions
- Command history - history navigation and persistence
- Multi-line input - multi-line editing
- Interactive shell - a file explorer shell
- Demo - the toy SQL shell in the animation above: context-aware completion, syntax highlighting, and multi-line statements
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 aRunon a prompt that is already closed. It matchesio.EOFas well as itselfprompt.ErrInterrupted: Ctrl+Ccontext.DeadlineExceeded: the context deadline passedcontext.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.