Files
gosh/prompt.go
T
Florian WaltherandClaude Opus 5.5 82f9c90a39 add custom prompts with PS1 and PS2
PS1 and PS2 support the backslash escapes of bash (\u \h \w \W \$ \t \j
\e \[ \] ...) and expand $VAR, ${...}, $(...) and $((...)) before each
prompt. Values of escapes and expansions are sanitized. Lines before the
last one and non-SGR escape sequences are written before readline's
prompt. Without PS1, the default prompt is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:49:19 +02:00

271 lines
7.2 KiB
Go

package main
// Custom prompts with $PS1 and $PS2, using the backslash escapes of bash:
//
// \u user name \h host name up to the first '.' \H host name
// \w working directory \W its last component (~ for home) \$ # for root, else $
// \t time 24h HH:MM:SS \T 12h HH:MM:SS \@ 12h am/pm \A 24h HH:MM \d date
// \j number of jobs \l terminal \s shell name \v \V version
// \n newline \r carriage return \a bell \e escape \nnn octal \\ backslash
// \[ ... \] begin/end of characters that take no space (escape sequences)
//
// After the escapes, $VAR, ${...}, $(...) and $((...)) are expanded (in the
// same pass, so the values of escapes are not expanded again).
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
)
// promptInfo holds the values for the escapes of a prompt string.
type promptInfo struct {
user, host, wd, home string
root bool
jobs int
tty string
now time.Time
}
// currentPromptInfo collects the promptInfo of the running shell.
func currentPromptInfo() promptInfo {
info := promptInfo{user: "?", host: "unknown", wd: "?", home: getHomeDir(),
root: os.Geteuid() == 0, jobs: len(jobTable), now: time.Now()}
if wd, err := os.Getwd(); err == nil {
info.wd = wd
}
if usr, err := user.Current(); err == nil && usr.Username != "" {
info.user = usr.Username
}
if host, err := os.Hostname(); err == nil {
info.host = host
}
if tty, err := os.Readlink("/proc/self/fd/0"); err == nil && strings.HasPrefix(tty, "/dev/") {
info.tty = filepath.Base(tty)
}
return info
}
// expandPrompt expands the escapes and $ expressions of a PS1 or PS2 value.
// The values of escapes like \w and of expansions are sanitized, so a
// directory or git branch name cannot write escape sequences to the
// terminal; colors have to be written as \e[...m in the prompt itself.
func expandPrompt(ps string, info promptInfo) (string, error) {
// $(...) in the prompt must not change $? or the status of the next
// command line
defer func(status int) { substStatus = status }(substStatus)
var b strings.Builder
for i := 0; i < len(ps); i++ {
ch := ps[i]
if ch == '$' {
value, n, err := expandDollar(ps[i:])
if err != nil {
return "", err
}
b.WriteString(sanitize(value))
i += n - 1
continue
}
if ch != '\\' || i+1 == len(ps) {
b.WriteByte(ch)
continue
}
i++
switch esc := ps[i]; esc {
case 'u':
b.WriteString(sanitize(info.user))
case 'h':
host, _, _ := strings.Cut(info.host, ".")
b.WriteString(sanitize(host))
case 'H':
b.WriteString(sanitize(info.host))
case 'w':
b.WriteString(sanitize(tildePath(info.wd, info.home)))
case 'W':
b.WriteString(sanitize(baseDir(info.wd, info.home)))
case '$':
if info.root {
b.WriteByte('#')
} else {
b.WriteByte('$')
}
case 't':
b.WriteString(info.now.Format("15:04:05"))
case 'T':
b.WriteString(info.now.Format("03:04:05"))
case '@':
b.WriteString(info.now.Format("03:04 PM"))
case 'A':
b.WriteString(info.now.Format("15:04"))
case 'd':
b.WriteString(info.now.Format("Mon Jan 02"))
case 'j':
b.WriteString(strconv.Itoa(info.jobs))
case 'l':
b.WriteString(sanitize(info.tty))
case 's':
b.WriteString("gosh")
case 'v':
b.WriteString(shortVersion(version))
case 'V':
b.WriteString(sanitize(version))
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 'a':
b.WriteByte('\a')
case 'e':
b.WriteByte('\x1b')
case '\\':
b.WriteByte('\\')
case '[', ']':
// Markers for bash's readline. Our readline finds the width of
// the prompt itself (see splitPrompt).
default:
if n := octalLen(ps[i:]); n > 0 {
code, _ := strconv.ParseUint(ps[i:i+n], 8, 8)
b.WriteByte(byte(code))
i += n - 1
} else {
// Unknown escapes are kept, like in bash
b.WriteByte('\\')
b.WriteByte(esc)
}
}
}
return b.String(), nil
}
// tildePath replaces the home directory at the start of path with ~.
func tildePath(path, home string) string {
if home != "" && home != "/" && (path == home || strings.HasPrefix(path, home+"/")) {
return "~" + strings.TrimPrefix(path, home)
}
return path
}
// baseDir returns the last component of path for \W: ~ for the home
// directory, / for the root directory.
func baseDir(path, home string) string {
if home != "" && path == home {
return "~"
}
if path == "/" {
return path
}
return filepath.Base(path)
}
// shortVersion returns major.minor of a version like v0.19.0 for \v.
func shortVersion(v string) string {
v = strings.TrimPrefix(v, "v")
parts := strings.SplitN(v, ".", 3)
if len(parts) >= 2 {
return sanitize(parts[0] + "." + parts[1])
}
return sanitize(v)
}
// octalLen returns the length of the octal number (up to 3 digits, at most
// 0377) at the start of s.
func octalLen(s string) int {
n := 0
for n < len(s) && n < 3 && s[n] >= '0' && s[n] <= '7' {
n++
}
if n == 3 && s[0] > '3' {
n = 2
}
return n
}
// splitPrompt splits an expanded prompt into what is written to the terminal
// before readline starts and the prompt that readline shows. readline only
// handles a single line and only knows the width of SGR sequences
// (\e[...m), so all lines but the last and every other control sequence of
// the last line (e.g. \e]0;title\a for the window title) go into prefix.
func splitPrompt(prompt string) (prefix, line string) {
var pre strings.Builder
if i := strings.LastIndexByte(prompt, '\n'); i >= 0 {
pre.WriteString(prompt[:i+1])
prompt = prompt[i+1:]
}
var b strings.Builder
for i := 0; i < len(prompt); {
ch := prompt[i]
switch {
case ch == '\x1b':
n := escapeLen(prompt[i:])
if seq := prompt[i : i+n]; isSGRSequence(seq) {
b.WriteString(seq)
} else {
pre.WriteString(seq)
}
i += n
case ch < ' ' || ch == 0x7f:
pre.WriteByte(ch)
i++
default:
b.WriteByte(ch)
i++
}
}
return pre.String(), b.String()
}
// escapeLen returns the length of the escape sequence at the start of s
// (which starts with ESC): a CSI sequence ESC [ ... final byte, an OSC
// sequence ESC ] ... BEL or ESC \, or ESC followed by one character.
func escapeLen(s string) int {
if len(s) < 2 {
return len(s)
}
switch s[1] {
case '[':
for i := 2; i < len(s); i++ {
if s[i] >= 0x40 && s[i] <= 0x7e {
return i + 1
}
}
return len(s)
case ']', 'P', '_', '^':
for i := 2; i < len(s); i++ {
if s[i] == '\a' {
return i + 1
}
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
return i + 2
}
}
return len(s)
}
return 2
}
// isSGRSequence reports whether seq is a complete ESC [ ... m sequence.
func isSGRSequence(seq string) bool {
return len(seq) >= 3 && strings.HasPrefix(seq, "\x1b[") && strings.HasSuffix(seq, "m") && isSGR(seq[2:len(seq)-1])
}
// promptString returns the expanded value of the prompt variable name (PS1
// or PS2), or ok == false if it is not set. An error in the prompt is reported and
// the default prompt is used.
func promptString(name string) (string, bool) {
ps, ok := os.LookupEnv(name)
if !ok {
return "", false
}
prompt, err := expandPrompt(ps, currentPromptInfo())
if err != nil {
fmt.Fprintf(os.Stderr, "gosh: %s: %s\n", name, sanitize(err.Error()))
return "", false
}
return prompt, true
}