From 041406a340546fdb809cf112cc28bdd2c36aeadc Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 23:46:11 +0200 Subject: [PATCH] show the exit status of a failed command in the prompt - user@host:dir [N] $ after a command failed with exit status N, the plain prompt again after a successful command - the status is colored bold red by default, configurable with the new GOSH_COLORS key error Co-Authored-By: Claude Opus 5.5 --- README.md | 31 +++++++++++++++++++++++++++---- main.go | 45 ++++++++++++++++++++++++++++++--------------- main_test.go | 38 +++++++++++++++++++++++++++++--------- 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index f010794..f11d7d8 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,13 @@ Features: - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files unless the pattern starts with a dot); quoted wildcards and values of variables are not expanded, a pattern without match is kept as is - - Colored prompt (user@host green, red for root; directory blue), - configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' - (keys user, root, path; values are ANSI SGR codes, empty = no color); - disabled with $NO_COLOR, TERM=dumb or when output is not a terminal + - Prompt user@host:dir $, with [N] before the $ if the last command + failed with exit status N + - Colored prompt (user@host green, red for root; directory blue; exit + status red), configurable with $GOSH_COLORS, e.g. + GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values + are ANSI SGR codes, empty = no color); 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 - Command lists: a ; b (run both), a && b (b only if a succeeded), @@ -289,6 +292,22 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf - Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem. - Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht. +### Prompt + +Der Prompt zeigt Benutzer, Rechner und Verzeichnis. Ist der letzte Befehl fehlgeschlagen, steht sein Exit-Status in eckigen Klammern davor, bis wieder ein Befehl erfolgreich war: + +``` +flow@host:~/src $ false +flow@host:~/src [1] $ gibtsnicht +gosh: gibtsnicht: command not found +flow@host:~/src [127] $ sleep 100 # mit Ctrl+C abgebrochen +^C +flow@host:~/src [130] $ true +flow@host:~/src $ +``` + +Typische Werte: `1` allgemeiner Fehler, `2` Syntaxfehler, `126` nicht ausführbar, `127` Befehl nicht gefunden, `128+n` durch Signal `n` beendet (`130` = Ctrl+C, `148` = mit Ctrl+Z angehalten unter Linux). Eine leere Eingabe ändert den Status nicht. + ### Farben Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`: @@ -298,6 +317,7 @@ Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie beste | `user` | `user@host` | `1;32` (fett grün) | | `root` | `user@host`, wenn man root ist | `1;31` (fett rot) | | `path` | aktuelles Verzeichnis | `1;34` (fett blau) | +| `error` | Exit-Status `[N]` nach einem Fehler | `1;31` (fett rot) | Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert: @@ -335,6 +355,9 @@ GOSH_COLORS='user=1;38;2;136;192;208:path=38;2;216;222;233' # Ozean: türkis und kursiv blau GOSH_COLORS='user=1;96:path=3;94' +# Fehler-Status gelb auf rotem Hintergrund +GOSH_COLORS='error=1;33;41' + # Root-Warnung: weiß auf rotem Hintergrund GOSH_COLORS='root=1;97;41' diff --git a/main.go b/main.go index 74cabb4..0cd64ef 100644 --- a/main.go +++ b/main.go @@ -612,21 +612,23 @@ func getPrompt() string { c := parseColors(os.Getenv("GOSH_COLORS")) colors = &c } - return buildPrompt(username, hostname, wd, os.Geteuid() == 0, colors) + return buildPrompt(username, hostname, wd, os.Geteuid() == 0, lastStatus, colors) } // promptColors holds the SGR parameters (e.g. "1;32") for the parts of the // prompt. An empty value means no color for that part. type promptColors struct { - user string // user@host - root string // user@host when running as root - path string // working directory + user string // user@host + root string // user@host when running as root + path string // working directory + error string // exit status of the last command, if it failed } -// defaultColors: bold green user, bold red root, bold blue directory -var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34"} +// defaultColors: bold green user, bold red root, bold blue directory, bold +// red exit status +var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34", error: "1;31"} -// parseColors reads a GOSH_COLORS value like "user=1;33:root=1;31:path=36" +// parseColors reads a GOSH_COLORS value like "user=1;33:path=36:error=31" // and overrides the corresponding defaults. Unknown keys and values that are // not plain SGR parameters (digits and ';') are ignored, so the variable // cannot be used to write arbitrary escape sequences to the terminal. @@ -644,6 +646,8 @@ func parseColors(spec string) promptColors { colors.root = value case "path": colors.path = value + case "error": + colors.error = value } } return colors @@ -669,20 +673,28 @@ func colorize(text, sgr string) string { return "\x1b[" + sgr + "m" + text + "\x1b[0m" } -// buildPrompt formats the prompt as user@host:path $. The parts are sanitized +// buildPrompt formats the prompt as user@host:path $, or user@host:path [N] $ +// if the last command failed with exit status N. The parts are sanitized // before the color codes are added, so only our own escape sequences end up // in the prompt. colors == nil disables colors. -func buildPrompt(username, hostname, path string, root bool, colors *promptColors) string { +func buildPrompt(username, hostname, path string, root bool, status int, colors *promptColors) string { userHost := sanitize(username + "@" + hostname) path = sanitize(path) + var failed string + if status != 0 { + failed = " [" + strconv.Itoa(status) + "]" + } if colors == nil { - return userHost + ":" + path + " $ " + return userHost + ":" + path + failed + " $ " } userColor := colors.user if root { userColor = colors.root } - return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + " $ " + if failed != "" { + failed = " " + colorize(failed[1:], colors.error) + } + return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + failed + " $ " } // useColor reports whether the prompt should be colored: stdout must be a @@ -1793,10 +1805,13 @@ Features: - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files unless the pattern starts with a dot); quoted wildcards and values of variables are not expanded, a pattern without match is kept as is - - Colored prompt (user@host green, red for root; directory blue), - configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' - (keys user, root, path; values are ANSI SGR codes, empty = no color); - disabled with $NO_COLOR, TERM=dumb or when output is not a terminal + - Prompt user@host:dir $, with [N] before the $ if the last command + failed with exit status N + - Colored prompt (user@host green, red for root; directory blue; exit + status red), configurable with $GOSH_COLORS, e.g. + GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values + are ANSI SGR codes, empty = no color); 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 - Command lists: a ; b (run both), a && b (b only if a succeeded), diff --git a/main_test.go b/main_test.go index 0441931..8b6e47e 100644 --- a/main_test.go +++ b/main_test.go @@ -1054,24 +1054,24 @@ func TestGetPrompt_EscapesControlCharacters(t *testing.T) { } func TestBuildPrompt_Color(t *testing.T) { - got := buildPrompt("flow", "host", "~/src", false, &defaultColors) + got := buildPrompt("flow", "host", "~/src", false, 0, &defaultColors) want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ " if got != want { t.Errorf("buildPrompt() = %q, want %q", got, want) } - got = buildPrompt("root", "host", "/", true, &defaultColors) + got = buildPrompt("root", "host", "/", true, 0, &defaultColors) if !strings.HasPrefix(got, "\x1b[1;31mroot@host") { t.Errorf("expected red user for root, got %q", got) } - got = buildPrompt("flow", "host", "~/src", false, nil) + got = buildPrompt("flow", "host", "~/src", false, 0, nil) if got != "flow@host:~/src $ " { t.Errorf("buildPrompt() without color = %q", got) } colors := promptColors{user: "38;5;208", path: ""} - got = buildPrompt("flow", "host", "~/src", false, &colors) + got = buildPrompt("flow", "host", "~/src", false, 0, &colors) if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " { t.Errorf("buildPrompt() with custom colors = %q", got) } @@ -1083,13 +1083,14 @@ func TestParseColors(t *testing.T) { want promptColors }{ {"", defaultColors}, - {"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34"}}, - {"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208"}}, - {"path=", promptColors{user: "1;32", root: "1;31", path: ""}}, + {"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34", error: "1;31"}}, + {"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208", error: "1;31"}}, + {"error=33", promptColors{user: "1;32", root: "1;31", path: "1;34", error: "33"}}, + {"path=", promptColors{user: "1;32", root: "1;31", path: "", error: "1;31"}}, {"path=0m\x1b[2J", defaultColors}, // escape sequences are rejected {"path=red", defaultColors}, // only numeric codes {"foo=1:user", defaultColors}, // unknown key, missing value - {"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36"}}, + {"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36", error: "1;31"}}, } for _, tc := range tests { if got := parseColors(tc.spec); got != tc.want { @@ -1098,8 +1099,27 @@ func TestParseColors(t *testing.T) { } } +func TestBuildPrompt_Status(t *testing.T) { + if got := buildPrompt("flow", "host", "~", false, 0, nil); got != "flow@host:~ $ " { + t.Errorf("status 0: %q", got) + } + if got := buildPrompt("flow", "host", "~", false, 127, nil); got != "flow@host:~ [127] $ " { + t.Errorf("status 127 without color: %q", got) + } + got := buildPrompt("flow", "host", "~", false, 1, &defaultColors) + want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~\x1b[0m \x1b[1;31m[1]\x1b[0m $ " + if got != want { + t.Errorf("status 1 with color = %q, want %q", got, want) + } + colors := defaultColors + colors.error = "" + if got := buildPrompt("flow", "host", "~", false, 2, &colors); !strings.HasSuffix(got, " [2] $ ") { + t.Errorf("status without error color = %q", got) + } +} + func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) { - got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, &defaultColors) + got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, 0, &defaultColors) if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") { t.Errorf("prompt contains control characters from path: %q", got) }