9
\$\begingroup\$

I had most of this functionality accomplished in BASH, but I was having trouble getting the last process's return value (even though I had a minimal working example). Plus, the escape codes made everything unreadable and I wasn't sure how to factor them out appropriately.

Since I know Python pretty well, I decided to rewrite it in Python, since I have now figured out enough shell that I felt confident embedding some Python into it.

The __future__ import should allow this to work in Python 2 or 3.

I do not know if this is cross-compatible with other shells, or if this is only likely to work in BASH, but another driver that pushed me in this direction was that desire. I am aware of the PROMPT_COMMAND for BASH.

Perhaps I could use a similar technique along with a BASH function to keep it all in shell and avoid the Python process altogether. However, I find this to be incredibly easy to read, work on, and modify without fear of breaking it - especially relative to shell scripting.

Another perhaps: maybe these escape codes are already available in the Python standard library. I looked at ncurses - I don't think that's the one I want to focus on.

I'm not sure if I factored the escape codes properly - perhaps I should have the constants point to the formatted escapes instead. But theoretically I could embed multiple codes into a single escape, instead of doing it multiple times for every tweak. Maybe there's a drawback to doing that my way, too.

PS1="\$(export LAST_RETURN_VALUE="\$?" WHOAMI="\$\(whoami\)" HOSTNAME ; python -c \"
from __future__ import print_function
import os 
import datetime

ESC = '\033[{0}m'
esc = ESC.format

OFF, BOLD, UNDERSCORE, BLINK = 0, 1, 4, 5
BLACK, BLACKBG = 30, 40
RED, REDBG = 31, 41
GREEN, GREENBG = 32, 42
YELLOW, YELLOWBG = 33, 43
BLUE, BLUEBG = 34, 44
MAGENTA, MAGENTABG = 35, 45
CYAN, CYANBG = 36, 46
WHITE, WHITEBG = 37, 47

LAST_RET = os.environ['LAST_RETURN_VALUE']

print(esc(YELLOW) + datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
      esc(GREEN) + os.environ['WHOAMI'] + esc(RED) + '@' +
      esc(CYAN) + os.environ['HOSTNAME'],
      esc(BLUE) + esc(BOLD) + os.getcwd() +
      esc(OFF))
if LAST_RET != '0':
    print(esc(MAGENTABG) + LAST_RET + esc(OFF), end='')
print('$ ', end='')
\" "

I'm getting impatient and want to go ahead and post this. I usually do more due diligence on code I write, but I'm up way past my bedtime already, and I think this is too clever not to share.

Here's the result:

screenshot of Termux using this prompt

\$\endgroup\$

3 Answers 3

6
\$\begingroup\$

As I write, there are already two good answers, the first, by Janos and a supplement, by Aaron Hall. This stands as a further supplement, and should be read in conjunction with the others.

  • There's no need to assign the formatting variables every time $PS1 is used. Instead, these can be ordinary shell variables, and they can be unset once they are interpolated into PS1.

  • You can avoid $? being reset by the invocation of date, simply by saving it into a local variable:

    $(x=$?;date +%Y-%m-%d\ %H:%M:%S;exit $x)
    

    However, that's unnecessary, because Bash has a \D{format} substitution for PS1.

  • We can make the printing of $? conditional by removing a leading zero (0 is the only value that begins with zero). The escape codes will still be emitted, but we have a net gain because the shell is doing less work.

  • Bash appears to understand \n in a PS1, so only the non-spacing strings after the newline need to be marked with \[ \].

My version:

set_PS1()
{
    local RESET=$(tput sgr0 )
    local BOLD=$(tput bold )
    local RED=$(tput setaf 1 )
    local GREEN=$(tput setaf 2 )
    local YELLOW=$(tput setaf 3 )
    local BLUE=$(tput setaf 4 )
    local MAGENTABG=$(tput setab 5 )
    local CYAN=$(tput setaf 6 )

    local WHOAMI='\u'
    local WHERE='\w'
    local HOSTNAME='\h'
    local DATE='\D{%Y-%m-%d %H:%M:%S}'
    local LAST_RET='${?#0}'

    local LINE_1="$YELLOW$DATE $GREEN$WHOAMI$RED@$CYAN$HOSTNAME $BLUE$BOLD$WHERE$RESET"
    local LINE_2="\\[$MAGENTABG\\]$LAST_RET\\[$RESET\\]"'\$ '

    PS1="$LINE_1\n$LINE_2"

    unset -f set_PS1
}

set_PS1

This is now evaluated entirely within Bash, with no need to fork every time the prompt is displayed. Expect that to be much more efficient.

Notes on portability

What I've written above is a straight Bash answer; my personal preference for PS1 is for it to be different for each shell, so I'm reminded which one I'm in. Thus, absolute consistency is an explicit non-goal for me.

However, the principle of creating local variables for the elements of the prompt works well for portability: we can isolate the shell-specific parts like this:

local DATE='\D{%Y-%m-%d %H:%M:%S}'
test ${BASH_VERSION+1} || DATE='$(date +%Y-%m-%d\ %H:%M:%S)'

or

local WHOAMI=$(whoami)
local WHERE='$(pwd)'
local HOSTNAME=$(hostname)
local DATE='$(date +%Y-%m-%d\ %H:%M:%S)'
local LAST_RET='${?#0}'

if [ ${BASH_VERSION:-} ]
then
    # Bash-specific overrides
    WHOAMI='\u'
    WHERE='\w'
    HOSTNAME='\h'
    DATE='\D{%Y-%m-%d %H:%M:%S}'
fi

# Now combine into lines
local LINE_1="$YELLOW$DATE $GREEN$WHOAMI$RED@$CYAN$HOSTNAME $BLUE$BOLD$WHERE$RESET"
local LINE_2="$MAGENTABG$LAST_RET$RESET"'\$ '

if [ ${BASH_VERSION:-} ]
then
    # Indicate non-spacing output for Bash readline
    LINE_2="\\[$MAGENTABG\\]$LAST_RET\\[$RESET\\]"'\$ '
fi

PS1="$LINE_1\n$LINE_2"

Afterthought

I should have used lowercase for the variable names above: that is the easiest way to ensure no conflict with the shell's own variables or with well-known environment variables.

\$\endgroup\$
3
  • \$\begingroup\$ This is very nice +1. I won't mind at all if I lose the check mark to this (CC Aaron) \$\endgroup\$
    – janos
    Sep 22, 2017 at 10:59
  • 1
    \$\begingroup\$ @TobySpeight the best answer should rise to the top - that's the social contract for these sites in my opinion, please don't disclaim a deserved accept. The only downside of this answer is a function name in the namespace, which could be easily unset. But I do worry about the unmarked escape codes - like there might be some downside for not marking them. I do want this to work in all shells, or barring that, as many as possible, can we say that about this answer? \$\endgroup\$
    – Aaron Hall
    Sep 22, 2017 at 14:54
  • \$\begingroup\$ I should have been clearer that this is Bash-specific, but it's intended to be much more efficient than the portable original. I leave it as an open question where the trade-off lies between efficiency and duplication here. Personally, I tend to use only a few shells, and I like them to have different prompts (to remind me when I'm not in Bash), so portability is a non-goal for me, and I should have been explicit about that. \$\endgroup\$ Oct 2, 2017 at 8:23
5
\$\begingroup\$

The typo

If we look at the beginning and the of the posted code:

PS1="\$(export LAST_RETURN_VALUE="\$?" WHOAMI="\$\(whoami\)" HOSTNAME ; python -c \"
from __future__ import print_function
... more python code
\" "

The end part is incorrect. The \" closes the python -c \", and the final " closes the PS1=", but a ) is missing to close the \$(export. (Pro tip: if you edit the script in vim, and press % on an opening (, it shows where's the closer, or emits an error beep if missing.)

I guess the last line was meant to be:

\")"

An easier way to escape stuff

Escaping things correctly within "..." can be quite painful in very long strings as in this example. In this particular example there's easy alternative using literal here documents:

PS1=$(cat << "EOF"
...
EOF
)

All the code you previously had within the "..." could go there in place of the ..., and it will be taken literally as if enclosed within single-quotes, but no need to escape ", or ' or $.

PS1=$(cat << "EOF"
$(export LAST_RETURN_VALUE="$?" WHOAMI="$(whoami)" HOSTNAME ; python -c "
from __future__ import print_function
import os 
import datetime

ESC = '\033[{0}m'
esc = ESC.format

OFF, BOLD, UNDERSCORE, BLINK = 0, 1, 4, 5
BLACK, BLACKBG = 30, 40
RED, REDBG = 31, 41
GREEN, GREENBG = 32, 42
YELLOW, YELLOWBG = 33, 43
BLUE, BLUEBG = 34, 44
MAGENTA, MAGENTABG = 35, 45
CYAN, CYANBG = 36, 46
WHITE, WHITEBG = 37, 47

LAST_RET = os.environ['LAST_RETURN_VALUE']

print(esc(YELLOW) + datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
      esc(GREEN) + os.environ['WHOAMI'] + esc(RED) + '@' +
      esc(CYAN) + os.environ['HOSTNAME'],
      esc(BLUE) + esc(BOLD) + os.getcwd())
if LAST_RET != '0':
    print(esc(OFF) + esc(MAGENTABG) + LAST_RET + esc(OFF), end='')
print(esc(OFF) + '$ ', end='')
"
)
EOF
)

Safety

If PROMPT_COMMAND is set, PS1 will be ignored. To avoid confusion, I would add unset PROMPT_COMMAND.

What's wrong with doing this in Python?

It's an additional dependency to worry about. If your friend asks you how to do that cool prompt, it gets less cool when you explain how he needs Python to get it. And running a Python script for every single command is a bit of a waste.

Why not do it in Bash?

The script basically sets variables, formats the date and prints stuff. That's easy enough to do in Bash. The script gets all its critical elements from Bash. It uses the Bash variable $? to get the exit code of the last command, the whoami command to get the current user, and the HOSTNAME variable that's already defined in the environment.

In other words, you already know everything it takes to make this work in Bash, and Python doesn't bring real benefits, the only thing left to do is to translate it to Bash.

This is pretty much a word-for-word translation of Python to Bash, with some minor improvements, such as \u instead of calling whoami, and unused variables dropped. I don't see the readability suffering significantly compared to the Python solution.

PS1=$(cat << "EOF"
$(
LAST_RET=$?
WHOAMI='\u'
HOSTNAME='\h'
DATE=$(date +'%Y-%m-%d %H:%M:%S')

OFF='\033[0m'
BOLD='\033[1m'
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
MAGENTABG='\033[45m'
CYAN='\033[36m'

echo "$YELLOW$DATE $GREEN$WHOAMI$RED@$CYAN$HOSTNAME $BLUE$BOLD$PWD"
test $LAST_RET = 0 || printf "$OFF$MAGENTABG$LAST_RET$OFF"
printf "$OFF\$ "
)
EOF
)
\$\endgroup\$
0
5
\$\begingroup\$

Improvements on Janos's answer

  • using tput to yield the escape codes for cross-platform compatibility
  • wrapping the escape codes in \[ and \] for proper shell calculation of length of immutable text
  • unquoting EOF and escaping the parts I wanted lazily evaluated to eagerly evaluate expensive operations only needed once
  • those things adding up to a faster PS1.

Thanks to Janos for getting me started, though, please give him an upvote.

The long story

If you're going to plan on throwing an implementation away, may as well code it up in Python - the perfect prototyping language. But I was concerned about the performance, and launching a separate Python process, perhaps multiple times.

Janos's answer does most of what I want, but in retrospect, it seems flawed - I'm concerned about proper escaping and closing of the escapes. In particular, I have a problem where the final line of the PS1, where I print the exit code when there's an error, but it the terminal won't let me backspace over any text about the length of the escape code + the exit signal on some systems.

While I was looking for the right solution, I discovered a program called tput which should provide a platform independent way of triggering the escape code instead of hard-coding them. But it still does not enclose the escape in a best-practice sort of way.

The tput man page is quite bare and actually makes no mention of this usage. man terminfo should tell you what numbers correspond to what colors though - and note that the background mappings are not the same as the foreground.

Also, I am concerned about running tput multiple times. I wanted to close over the results (like a closure) when the string was parsed, not execute them every time.

I found that, in order to eval only part of the heredoc, without executing the other parts, I would need to protect the parts I want lazily evaluated by escaping all relevant ', \, and $ symbols. Removing the " from around the delimiter word, in this case, EOF, allows the expansion to take place.

Also important are the \[ and \] around the escape sequences - this tells the shell to not count the escape sequences when determining what you can backspace over or not.

This left me with a very snappy PS1 that seems to pop up before I can take my finger off the Enter key:

PS1=$(cat << EOF
\$(
LAST_RET=\$?
WHOAMI=\u
HOSTNAME=\h
RESET='\[$(tput sgr0 )\]'
BOLD='\[$(tput bold )\]'
RED='\[$(tput setaf 1 )\]'
GREEN='\[$(tput setaf 2 )\]'
YELLOW='\[$(tput setaf 3 )\]'
BLUE='\[$(tput setaf 4 )\]'
MAGENTABG='$(tput setab 5 )'
CYAN='\[$(tput setaf 6 )\]'

DATE=\$(date +"%Y-%m-%d %H:%M:%S")
echo "\$YELLOW\$DATE \$GREEN\$WHOAMI\$RED@\$CYAN\$HOSTNAME \$BLUE\$BOLD\$PWD\$RESET"
test \$LAST_RET = 0 || printf "\$MAGENTABG\$LAST_RET\$RESET"
printf "\\\$ "
)
EOF
)

Again, note, I escaped everything that I wanted lazily evaluated (especially, and perhaps easiest to miss, the \\\$ - which is basically an escaped backslash followed by an escaped dollar sign), and left everything open for shell expansion that I wanted to be eagerly evaluated on parsing.

(No big deal, but I also got one fewer RESET expansion by moving them to the end of the sequences.)

Things I wanted eagerly evaluated are:

  • name (WHOAMI)
  • HOSTNAME
  • escape sequences
  • The actual heredoc

Things I wanted lazily evaluated are:

  • the containing subshell inside the heredoc
  • LAST_RET
  • DATE
  • all printf and echos

Ideas for further improvement that need more exploration

It's conceivable that I could avoid lots of escaping by using multiple heredocs. That would have the effect of making the code a little more readable and maintainable.

Also - the Python looked much nicer. Perhaps I could compile a kind of cython executable that would run much faster.

Another point - I worry that the color for the background isn't quite right.

I had to install ncurses on my android termux app to get tput. It was a simple exercise, but makes me worry about cross-compatibility again.

Conclusion

Assuming one can maintain this code, this may be arguably the best way to go for performance and long-run cross-compatibility. Please enjoy.

Update

Since switching to NixOS I have wanted my nix-shell to use my standard PS1 instead of simply telling me I'm in the nix-shell, so I have added that to my PS1:

  programs.bash.promptInit = ''
    set_PS1()
    {
        local RESET=$(tput sgr0 )
        local BOLD=$(tput bold )
        local RED=$(tput setaf 1 )
        local GREEN=$(tput setaf 2 )
        local YELLOW=$(tput setaf 3 )
        local BLUE=$(tput setaf 4 )
        local MAGENTA=$(tput setaf 5 )
        local MAGENTABG=$(tput setab 5 )
        local CYAN=$(tput setaf 6 )
        local WHOAMI='\u'
        local WHERE='\w'
        local HOSTNAME='\h'
        local DATE='\D{%Y-%m-%d %H:%M:%S}'
        local EXECUTE=$
        local LAST_RET={?#0}
        local NIX=$( if [[ -n "$IN_NIX_SHELL" ]];
                     then echo "nix-shell";
                     else echo ""; fi )
        local LINE_1a="$YELLOW$DATE $GREEN$WHOAMI$MAGENTA@$CYAN$HOSTNAME"
        local LINE_1b="$BLUE$BOLD$WHERE$RESET"
        local LINE_2="\\[$MAGENTA\\]$EXECUTE$LAST_RET\\[$RESET\\]$NIX"'\$ '
        PS1="$LINE_1a $LINE_1b\n$LINE_2"
    }

    set_PS1
  '';
```
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.