add colored prompt

- user@host in bold green (bold red for root), directory in bold blue
- colors are disabled with NO_COLOR, TERM=dumb or when stdout is not a terminal
- parts are sanitized before adding the color codes
- only replace the home directory with ~ on a path boundary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian WaltherandClaude Opus 5.5 committed 2026-10-03 17:31:09 +02:00
1 parent 37d6802815
commit 7afcecd053
3 files changed
+78 -7

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ Features:
override with $GOSH_HISTFILE); lines starting with a space are not saved override with $GOSH_HISTFILE); lines starting with a space are not saved
- Tab completion for commands and file paths - Tab completion for commands and file paths
- Tilde expansion (~, ~/path) at the start of unquoted words - Tilde expansion (~, ~/path) at the start of unquoted words
- Colored prompt (user@host green, red for root; directory blue),
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default using an unset variable is an error, use ${VAR:-} for an empty default
+40 -7
View File
@@ -303,16 +303,47 @@ func getPrompt() string {
hostname = "unknown" hostname = "unknown"
} }
// Shorten the path for display // Replace the home directory with ~
// Use only the last component if in home directory
home := getHomeDir() home := getHomeDir()
if home != "" && strings.HasPrefix(wd, home) { if home != "" && (wd == home || strings.HasPrefix(wd, home+"/")) {
// Replace home with ~ wd = "~" + strings.TrimPrefix(wd, home)
displayPath := "~" + strings.TrimPrefix(wd, home)
return sanitize(fmt.Sprintf("%s@%s:%s $ ", username, hostname, displayPath))
} }
return sanitize(fmt.Sprintf("%s@%s:%s $ ", username, hostname, wd)) return buildPrompt(username, hostname, wd, os.Geteuid() == 0, useColor())
}
// ANSI escape sequences used for the prompt
const (
colorReset = "\x1b[0m"
colorUser = "\x1b[1;32m" // bold green
colorRoot = "\x1b[1;31m" // bold red
colorPath = "\x1b[1;34m" // bold blue
)
// buildPrompt formats the prompt as user@host:path $. The parts are sanitized
// before the color codes are added, so only our own escape sequences end up
// in the prompt.
func buildPrompt(username, hostname, path string, root, color bool) string {
userHost := sanitize(username + "@" + hostname)
path = sanitize(path)
if !color {
return userHost + ":" + path + " $ "
}
userColor := colorUser
if root {
userColor = colorRoot
}
return userColor + userHost + colorReset + ":" + colorPath + path + colorReset + " $ "
}
// useColor reports whether the prompt should be colored: stdout must be a
// terminal, TERM must not be "dumb" and NO_COLOR (https://no-color.org) must
// not be set.
func useColor() bool {
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
return false
}
return readline.IsTerminal(int(os.Stdout.Fd()))
} }
// shellCompleter implements readline.AutoCompleter for contextual completion // shellCompleter implements readline.AutoCompleter for contextual completion
@@ -666,6 +697,8 @@ Features:
override with $GOSH_HISTFILE); lines starting with a space are not saved override with $GOSH_HISTFILE); lines starting with a space are not saved
- Tab completion for commands and file paths - Tab completion for commands and file paths
- Tilde expansion (~, ~/path) at the start of unquoted words - Tilde expansion (~, ~/path) at the start of unquoted words
- Colored prompt (user@host green, red for root; directory blue),
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default`) using an unset variable is an error, use ${VAR:-} for an empty default`)
return nil return nil
+36
View File
@@ -1004,6 +1004,7 @@ func TestSanitize(t *testing.T) {
} }
func TestGetPrompt_EscapesControlCharacters(t *testing.T) { func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
t.Setenv("NO_COLOR", "1")
origDir, _ := os.Getwd() origDir, _ := os.Getwd()
defer os.Chdir(origDir) defer os.Chdir(origDir)
@@ -1024,6 +1025,41 @@ func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
} }
} }
func TestBuildPrompt_Color(t *testing.T) {
got := buildPrompt("flow", "host", "~/src", false, true)
want := colorUser + "flow@host" + colorReset + ":" + colorPath + "~/src" + colorReset + " $ "
if got != want {
t.Errorf("buildPrompt() = %q, want %q", got, want)
}
got = buildPrompt("root", "host", "/", true, true)
if !strings.HasPrefix(got, colorRoot+"root@host") {
t.Errorf("expected red user for root, got %q", got)
}
got = buildPrompt("flow", "host", "~/src", false, false)
if got != "flow@host:~/src $ " {
t.Errorf("buildPrompt() without color = %q", got)
}
}
func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, true)
if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") {
t.Errorf("prompt contains control characters from path: %q", got)
}
if !strings.Contains(got, `evil\x1b[2J\x07`) {
t.Errorf("expected escaped path in prompt, got %q", got)
}
}
func TestUseColor_NoColor(t *testing.T) {
t.Setenv("NO_COLOR", "1")
if useColor() {
t.Error("expected no color when NO_COLOR is set")
}
}
func TestFilePathCompleter_SkipsControlCharacters(t *testing.T) { func TestFilePathCompleter_SkipsControlCharacters(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
for _, name := range []string{"good", "go\x1b[31mred"} { for _, name := range []string{"good", "go\x1b[31mred"} {