blog

When did I run that command?

Update your Bash prompt with the command start time.

 ·  7 min read

By Sky  ·  @countrmeasure

A terminal showing the text "This prompt's time updates!"

I often ask “What time did I run that command?”

For a long time I couldn’t find out quickly and accurately.

It seems easy to do with other shells, but I couldn’t find a way to do it with Bash, so I cooked one up. Here it is.

An terminal with a prompt whose timestamp updates when a command runs.
The updating Bash prompt in action.

How is this an improvement?

For a long time people have included the time in the Bash prompt at the time the prompt is written.

The problem with this is that a prompt can sit for any length of time before being used to run a command; the time in the prompt and command start time are uncoupled.

Here’s an example.

A terminal with prompts showing times which don't change.
The problem with the established approach.

The example shows me working in the terminal at 20:45, going to to do something else for five minutes, then coming back to the terminal at 20:50. The prompt for the command I ran when I came back at 20:50 continues to show 20:45 though, and it’s only the prompt printed after that final command which shows the time as 20:50.

Sure, I can get into the habit of looking at the prompt which appears after a command to show me when it ran, but that’s clunky and unintuitive. Also, that workaround breaks for long-running commands, because the next prompt shows the time the command finished, which might be a long time after it was executed.

My solution modifies the prompt to show the time when the command is executed, not the time the prompt was written, removing that confusion.

How can I do this too?

First, make a backup of your .bashrc file because we’re about to change it.

In .bashrc, delete or comment out the existing definitions of PS0, PS1 and PS2 (some of which may not be present) and put this snippet in their place.

DIRECTORY="\w"
DOUBLE_SPACE="  "
NEWLINE="\n"
NO_COLOUR="\e[00m"
PRINTING_OFF="\["
PRINTING_ON="\]"
PROMPT_COLOUR="\e[0;33m"
PS1_PROMPT="\$"
PS2_PROMPT=">"
RESTORE_CURSOR_POSITION="\e[u"
SAVE_CURSOR_POSITION="\e[s"
SINGLE_SPACE=" "
TIMESTAMP="\A"
TIMESTAMP_PLACEHOLDER="--:--"

move_cursor_to_start_of_ps1() {
    command_rows=$(history 1 | wc -l)
    if [ "$command_rows" -gt 1 ]; then
        let vertical_movement=$command_rows+1
    else
        command=$(history 1 | sed 's/^\s*[0-9]*\s*//')
        command_length=${#command}
        ps1_prompt_length=${#PS1_PROMPT}
        let total_length=$command_length+$ps1_prompt_length
        let lines=$total_length/${COLUMNS}+1
        let vertical_movement=$lines+1
    fi
    tput cuu $vertical_movement
}

PS0_ELEMENTS=(
    "$SAVE_CURSOR_POSITION" "\$(move_cursor_to_start_of_ps1)"
    "$PROMPT_COLOUR" "$TIMESTAMP" "$NO_COLOUR" "$RESTORE_CURSOR_POSITION"
)
PS0=$(IFS=; echo "${PS0_ELEMENTS[*]}")

PS1_ELEMENTS=(
    # Empty line after last command.
    "$NEWLINE"
    # First line of prompt.
    "$PRINTING_OFF" "$PROMPT_COLOUR" "$PRINTING_ON"
    "$TIMESTAMP_PLACEHOLDER" "$DOUBLE_SPACE" "$DIRECTORY" "$PRINTING_OFF"
    "$NO_COLOUR" "$PRINTING_ON" "$NEWLINE"
    # Second line of prompt.
    "$PRINTING_OFF" "$PROMPT_COLOUR" "$PRINTING_ON" "$PS1_PROMPT"
    "$SINGLE_SPACE" "$PRINTING_OFF" "$NO_COLOUR" "$PRINTING_ON"
)
PS1=$(IFS=; echo "${PS1_ELEMENTS[*]}")

PS2_ELEMENTS=(
    "$PRINTING_OFF" "$PROMPT_COLOUR" "$PRINTING_ON" "$PS2_PROMPT"
    "$SINGLE_SPACE" "$PRINTING_OFF" "$NO_COLOUR" "$PRINTING_ON"
)
PS2=$(IFS=; echo "${PS2_ELEMENTS[*]}")

shopt -s histverify

Now open a new shell and try it out.

You can change the digits 33 in the PROMPT_COLOUR variable for a different prompt colour. See the FG Code column in this colour table in Wikpedia's ANSI escape code article for colour codes.

How does it work?

The overview

First the PS1 prompt is printed in the terminal and it waits for a command. Once a command is typed and Enter is pressed, PS0 is expanded. This runs the move_cursor_to_start_of_ps1 function. This function moves the cursor to the beginning of the first line of the PS1 prompt, overwrites the string --:-- with the current time, then returns the cursor to its earlier position. Then the command executes.

Now let’s look at each portion of the code in more detail.

The constants

DIRECTORY="\w"
DOUBLE_SPACE="  "
NEWLINE="\n"
NO_COLOUR="\e[00m"
PRINTING_OFF="\["
PRINTING_ON="\]"
PROMPT_COLOUR="\e[0;33m"
PS1_PROMPT="\$"
PS2_PROMPT=">"
RESTORE_CURSOR_POSITION="\e[u"
SAVE_CURSOR_POSITION="\e[s"
SINGLE_SPACE=" "
TIMESTAMP="\A"
TIMESTAMP_PLACEHOLDER="--:--"

Strictly speaking, these constants don’t need to be created, but I find that they make building, reading and debugging PS0, PS1 and PS2 much easier.

The characters which start with a \ are interpreted in the prompt as explained in the Bash Reference Manual section on controlling the prompt.

Constant values which don’t start with a \ are strings which will be printed in the prompt.

The SAVE_CURSOR_POSITION and RESTORE_CURSOR_POSITION values are escape codes which save and restore the cursor position but aren’t printed in the prompt.

The PROMPT_COLOUR and NO_COLOUR values are escape codes which set and unset a colour but aren’t printed in the prompt.

The cursor repositioning function

move_cursor_to_start_of_ps1() {
    command_rows=$(history 1 | wc -l)
    if [ "$command_rows" -gt 1 ]; then
        let vertical_movement=$command_rows+1
    else
        command=$(history 1 | sed 's/^\s*[0-9]*\s*//')
        command_length=${#command}
        ps1_prompt_length=${#PS1_PROMPT}
        let total_length=$command_length+$ps1_prompt_length
        let lines=$total_length/${COLUMNS}+1
        let vertical_movement=$lines+1
    fi
    tput cuu $vertical_movement
}

This is where the magic happens.

No matter how many lines the command spans and where the cursor ends up as a result, the cursor has to be repositioned to the beginning of the prompt to overwrite the --:-- placeholder with the time the command was run.

This function works out how many lines are in the command, and repositions the cursor accordingly.

Here are examples of commands of varying lengths which must be handled.

A terminal showing several commands of different lengths.
Commands with different lengths and line counts must be handled.

In move_cursor_to_start_of_ps1, the command_rows variable gets the number of lines the command is split over.

If it’s a multi-line command, we take the first path through the if statement. In this case, the number of lines below the top line of the PS1 prompt that the cursor will be is the number of lines in the command plus one, so that’s what we’ll set vertical_movement to. The tput command moves the cursor up by that number of lines and places it at the start of the top line of the PS1 prompt.

If it’s a single-line command, we take the second path through the if statement. We get the length of the command and add add it to the length of the second line of the PS1 prompt, then divide that by the number of columns in the terminal window (the COLUMNS variable) to get the number of lines the command takes up. This allows for long commands which wrap over multiple lines. Then we use the tput command to place the cursor at the start of the top line of the PS1 prompt.

The PS variables

PS0_ELEMENTS=(
    "$SAVE_CURSOR_POSITION" "\$(move_cursor_to_start_of_ps1)"
    "$PROMPT_COLOUR" "$TIMESTAMP" "$NO_COLOUR" "$RESTORE_CURSOR_POSITION"
)
PS0=$(IFS=; echo "${PS0_ELEMENTS[*]}")

PS1_ELEMENTS=(
    # Empty line after last command.
    "$NEWLINE"
    # First line of prompt.
    "$PRINTING_OFF" "$PROMPT_COLOUR" "$PRINTING_ON"
    "$TIMESTAMP_PLACEHOLDER" "$DOUBLE_SPACE" "$DIRECTORY" "$PRINTING_OFF"
    "$NO_COLOUR" "$PRINTING_ON" "$NEWLINE"
    # Second line of prompt.
    "$PRINTING_OFF" "$PROMPT_COLOUR" "$PRINTING_ON" "$PS1_PROMPT"
    "$SINGLE_SPACE" "$PRINTING_OFF" "$NO_COLOUR" "$PRINTING_ON"
)
PS1=$(IFS=; echo "${PS1_ELEMENTS[*]}")

PS2_ELEMENTS=(
    "$PRINTING_OFF" "$PROMPT_COLOUR" "$PRINTING_ON" "$PS2_PROMPT"
    "$SINGLE_SPACE" "$PRINTING_OFF" "$NO_COLOUR" "$PRINTING_ON"
)
PS2=$(IFS=; echo "${PS2_ELEMENTS[*]}")

The point of declaring all those constants earlier was to make the prompt declarations more semantic, and not just the usual one-liner soup of backslashes and square brackets. I think it works.

For each of PS0, PS1 and PS2, we make a list of their elements, then join the elements together.

For comparison, here’s the same code written in the more traditional fashion.

PS0="\e[s\$(move_cursor_to_start_of_ps1)\e[0;33m\A\e[00m\e[u"
PS1="\n\[\e[0;33m\]--:--  \w\[\e[00m\]\n\[\e[0;33m\]$ \[\e[00m\]"
PS2="\[\e[0;33m\]> \[\e[00m\]"

This is far more terse, but it’s also much more difficult to read, modify and debug.

But let’s move away from style and back to functionality.

You’re probably already familiar with the PS1 prompt. All I’ll say about this particular PS1 is that we have to switch the prompt colour on then off for each of the two lines, otherwise the colourisation of the second line with the $ prompt doesn’t work properly.

The PS0 variable is comparatively recent, apparently having been introduced to Bash in 2016. It is expanded after the Enter key is pressed and before the command is run. You can see that this particular PS0 is saving the position of the cursor once Enter is pressed, moving the cursor to the start of the prompt, writing the current time there which overwrites the --:-- placeholder, then returning the cursor to the saved position ready for the command to execute.

You’ll notice that the PS2 variable is also declared. Once you’ve coloured PS1, you might as well colour PS2 to match it.

The shopt command

shopt -s histverify

Last but not least, this covers a corner case by showing the result of a history substitution command in a subsequent prompt for verification rather than executing it immediately. Without it, history substitution commands like !! and !274 cause the time to be written one line too low when the command is run.

I won’t spend time explaining why. If you’re interested, comment it out and see how your prompt behaves when you give it !!. It has to do with the position of the cursor when PS0 is expanded.

One less niggle

The command line is simple and satisfying, and if you spend hours in it every day it’s also full of little niggles.

This tweak removes one of those niggles for me. If you’re also a Bash user, I hope it can do the same for you.