From 7afcecd053131471e27d938bb1829df8216d27fd Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 17:31:09 +0200 Subject: [PATCH] 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 --- README.md | 2 ++ main.go | 47 ++++++++++++++++++++++++++++++++++++++++------- main_test.go | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2b6aba3..1c8b99b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Features: override with $GOSH_HISTFILE); lines starting with a space are not saved - Tab completion for commands and file paths - 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}); using an unset variable is an error, use ${VAR:-} for an empty default diff --git a/main.go b/main.go index 34b7381..6d510b3 100644 --- a/main.go +++ b/main.go @@ -303,16 +303,47 @@ func getPrompt() string { hostname = "unknown" } - // Shorten the path for display - // Use only the last component if in home directory + // Replace the home directory with ~ home := getHomeDir() - if home != "" && strings.HasPrefix(wd, home) { - // Replace home with ~ - displayPath := "~" + strings.TrimPrefix(wd, home) - return sanitize(fmt.Sprintf("%s@%s:%s $ ", username, hostname, displayPath)) + if home != "" && (wd == home || strings.HasPrefix(wd, home+"/")) { + wd = "~" + strings.TrimPrefix(wd, home) } - 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 @@ -666,6 +697,8 @@ Features: override with $GOSH_HISTFILE); lines starting with a space are not saved - Tab completion for commands and file paths - 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}); using an unset variable is an error, use ${VAR:-} for an empty default`) return nil diff --git a/main_test.go b/main_test.go index 3c9ddd1..95a61ef 100644 --- a/main_test.go +++ b/main_test.go @@ -1004,6 +1004,7 @@ func TestSanitize(t *testing.T) { } func TestGetPrompt_EscapesControlCharacters(t *testing.T) { + t.Setenv("NO_COLOR", "1") origDir, _ := os.Getwd() 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) { dir := t.TempDir() for _, name := range []string{"good", "go\x1b[31mred"} {