Friendly Interactive Shell (more commonly known as
Fish) is a popular alternative to the more ubiquitous shells like Bash and Zsh.
If you’ve been a command line aficionado, you have probably seen fancy command
line prompts that conveniently show you everything you need to know from current
working directory, to VCS status, to outside temperature. Maybe you are enjoying
one right now!
This is what my prompt looks like, which, arguably, is on the lighter weight
side:
Zsh prompt based on the Bira theme from Oh-my-zsh.
The additional information is very handy, but it also clutters the terminal
scrollback. More than than, at work I often need to copy and paste my terminal
logs to keep record of the actions I took for one task or another. Editing the
fluff from my prompt gets annoying fast.
Transient prompt
is a feature offered by some shells where your command prompt is normally
rendered in all its helpful glory, but is collapsed into a more minimalistic
representation of itself once you execute the command. I think it was brought to
mainstream by Powerlevel10k, but these days it’s offered by many other tools and
shells.
Bringing in an extra dependency just for that didn’t sit right with me, and I
decided to implement it myself.
If you just want to make your own transient prompt and aren't interested in under the hood dealings, grab zzhaolei/transient.fish. What follows is a simplified and less versatile version of that plugin, but it's easier for understanding.
The basics
In Fish, you can
customize the look of your prompt
by overriding the
fish_prompt
function. So, in theory, all we need to do is to detect that we are about to
execute a command, re-render the prompt and signal it that this is the “final”
render.
Let’s start by defining two functions which would render the compact and rich
versions of the prompt. For brevity, I used a simplified version of my own
prompt, leaving just enough for the demonstration:
1
2
3
4
5
6
7
8
9
function__prompt_compact--description"Minimal prompt form for scrollback"echo-n-s(set_color cyan)'$'(set_color normal)' 'endfunction__prompt_rich--description"Extended prompt form for the current command"set-lnormal(set_color normal)echo-s'╭─'(prompt_login)' '(set_color$fish_color_cwd)(prompt_pwd)$normal(fish_vcs_prompt)$normalecho-n-s'╰─ $ 'end
Then, we define the actual fish_prompt function that selects between the two.
Our signal variable will be $TRANSIENT with normal and transient values
corresponding to “rich” and “compact” versions of the prompt:
1
2
3
4
5
6
7
functionfish_prompt--description'Write out the prompt'iftest"$TRANSIENT"= transient
__prompt_compactelse__prompt_richendend
Triggering content
Now, in an ideal world, Fish would actually automatically re-render the prompt
before command execution, as well as flagging that it is the final render. If it
were the case, we would have been pretty much done here. Something like that has
even been discussed in the
issue 7602, but due to
some technical hurdles it wasn’t implemented quite like that.
So, instead we have to take a harder path of predicting when command execution
would occur, triggering the repaints manually and then resuming the normal flow.
This workaround has been outlined in the
pull request 8142, which
is based on rebinding Enter key handler to a custom function:
function__transient_execute# Expanding abbreviations ensures that syntax validity is not undone later,
# leaving prompt in the unexpectedly transient state.
# Suppressing autosuggestions ensures they don't interfere either, since
# they are not yet accepted and won't be executed.
commandline--functionexpand-abbr suppress-autosuggestion
# If the command is syntactically valid (including empty command),
# it may be executed, which requires replacing the prompt with transient.
ifcommandline--is-valid||test-z"$(commandline)"# However, if we are in a list selection mode (completion or history search),
# we don't actually execute the command, but accept whatever the current suggestion is.
ifcommandline--paging-mode&&test-n"$(commandline)"commandline-f accept-autosuggestion
return0end# About to execute the command, flag for the transient mode.
set--globalTRANSIENT transient
# Repaint the prompt, then execute the command.
commandline--functionrepaint executereturn0end# The command won't be executed, so simply trigger the default behavior.
commandline-f execute
end# Key: enter
bind--user--mode default \r __transient_execute
bind--user--mode insert \r __transient_execute
# Key: new line
bind--user--mode default \cj __transient_execute
bind--user--mode insert \cj __transient_execute
What I am not thrilled about in this approach is that __transient_execute has
to replicate the logic Fish does internally when Enter is hit because not every
Enter leads to command being executed. For example, if there is an open quote,
Fish would simply start a new line, and we want to keep the prompt in its rich
form in that case. If Fish ever changed its logic, for example adding new
selection modes or completion behavior, this handler would have to be updated to
match, or the shell won’t work quite right.
But oh well, words are cheap and code is gold, so since I’m not about to send a
PR with a “proper” implementation, I shouldn’t be bitching about it either 😅
Minor omission
We need to come back to our fish_prompt for a second, because I left out two
important things out of it.
First, you may have noticed that __transient_execute sets $TRANSIENT to
transient, but it is never reverted to normal. If we don’t fix that, every
prompt but the first one would be stuck in the compact form. Fortunately, since
we know that the compact rendering is the final one for this instance of the
prompt, we can simply reset it back to normal every time after
__prompt_compact is called.
The second issue would reveal itself if you try something like this:
A part of the original prompt was not erased!
This happens because our rich version of the prompt occupies two terminal lines,
but the compact one — only one. When Fish renders the compact prompt, it doesn’t
know that there are more lines that need to be erased, but we can take care of
it ourselves.
The complete version of fish_prompt looks like this, note added lines 4 and 5:
1
2
3
4
5
6
7
8
9
10
functionfish_prompt--description'Write out the prompt'iftest"$TRANSIENT"= transient
__prompt_compactecho-en\e\[0J # Clear from cursor to end of screen
set--globalTRANSIENT normal
return0else__prompt_richendend
More key bindings
We are not quite done yet, executing the command is not the only case when Fish
“abandons” the prompt, which therefore must be compactified. Ctrl+C and
Ctrl+D do that too, so we need to handle them:
function__transient_ctrl_c_execute# If the command line is empty, we won't be jumping to the new line, so no
# transient flag is required.
iftest"$(commandline --current-buffer)"=""commandline--functioncancel-commandline
return0end# Command is not empty, so we'll redraw this in a transient form and create
# a fresh and empty one.
set--globalTRANSIENT transient
# Second repaint is apparently necessary to redraw the next prompt in a full mode.
# I am not entirely following the logic behind this line, so all credit to @zzhaolei.
commandline--functionrepaint cancel-commandline kill-inner-line repaint-mode repaint
end# Key: Ctrl-C
bind--user--mode default \cc __transient_ctrl_c_execute
bind--user--mode insert \cc __transient_ctrl_c_execute
function__transient_ctrl_d_execute# If the current command line is not empty we shouldn't exit.
iftest"$(commandline --current-buffer)" !=""return0end# Repaint the last prompt as transient and exit.
set--globalTRANSIENT transient
commandline--functionrepaint exitend# Key: Ctrl-D
bind--user--mode default \cd __transient_ctrl_d_execute
bind--user--mode insert \cd __transient_ctrl_d_execute
They follow the same template as Enter: replicate the default Fish behavior,
identify cases when the prompt needs repainting and set the transient flag. More
verbose than I wish it had to be, but it works.
Putting it all together
This is what the final prompt would look like:
Previous commands are marked with a simple $ prefix, and the current prompt has all the nice things.All combined, this is less than 100 lines of code, comments included, which is not too bad.
function__prompt_compact--description"Minimal prompt form for scrollback"echo-n-s(set_color cyan)'$'(set_color normal)' 'endfunction__prompt_rich--description"Extended prompt form for the current command"set-lnormal(set_color normal)echo-s'╭─'(prompt_login)' '(set_color$fish_color_cwd)(prompt_pwd)$normal(fish_vcs_prompt)$normalecho-n-s'╰─ $ 'endfunctionfish_prompt--description'Write out the prompt'iftest"$TRANSIENT"= transient
__prompt_compactecho-en\e\[0J # Clear from cursor to end of screen
set--globalTRANSIENT normal
return0else__prompt_richendend# Key binding handlers that emulate regular fish behavior, but also repaint the
# prompt in its short form when necessary.
# https://github.com/zzhaolei/transient.fish/blob/7091a1ef574e4c2d16779e59d37ceb567128c787/conf.d/transient.fish
function__transient_execute# Expanding abbreviations ensures that syntax validity is not undone later,
# leaving prompt in the unexpectedly transient state.
# Suppressing autosuggestions ensures they don't interfere either, since
# they are not yet accepted and won't be executed.
commandline--functionexpand-abbr suppress-autosuggestion
# If the command is syntactically valid (including empty command),
# it may be executed, which requires replacing the prompt with transient.
ifcommandline--is-valid||test-z"$(commandline)"# However, if we are in a list selection mode (completion or history search),
# we don't actually execute the command, but accept whatever the current suggestion is.
ifcommandline--paging-mode&&test-n"$(commandline)"commandline-f accept-autosuggestion
return0end# About to execute the command, flag for the transient mode.
set--globalTRANSIENT transient
# Repaint the prompt, then execute the command.
commandline--functionrepaint executereturn0end# The command won't be executed, so simply trigger the default behavior.
commandline-f execute
endfunction__transient_ctrl_c_execute# If the command line is empty, we won't be jumping to the new line, so no
# transient flag is required.
iftest"$(commandline --current-buffer)"=""commandline--functioncancel-commandline
return0end# Command is not empty, so we'll redraw this in a transient form and create
# a fresh and empty one.
set--globalTRANSIENT transient
# Second repaint is apparently necessary to redraw the prompt in a full mode.
# I am not entirely following the logic behind this line, all credit to @zzhaolei.
commandline--functionrepaint cancel-commandline kill-inner-line repaint-mode repaint
endfunction__transient_ctrl_d_execute# If the current command line is not empty we shouldn't exit.
iftest"$(commandline --current-buffer)" !=""return0end# Repaint the last prompt as transient and exit.
set--globalTRANSIENT transient
commandline--functionrepaint exitend# Key: enter
bind--user--mode default \r __transient_execute
bind--user--mode insert \r __transient_execute
# Key: new line
bind--user--mode default \cj __transient_execute
bind--user--mode insert \cj __transient_execute
# Key: Ctrl-D
bind--user--mode default \cd __transient_ctrl_d_execute
bind--user--mode insert \cd __transient_ctrl_d_execute
# Key: Ctrl-C
bind--user--mode default \cc __transient_ctrl_c_execute
bind--user--mode insert \cc __transient_ctrl_c_execute
In this example I ignored fish_right_prompt and fish_mode_prompt for brevity
and because I personally don’t use them, so I leave them as an exercise to the
reader, it’s all the same principle.
You can put this code directly into ~/.config/fish/config.fish or, to keep
things tidy, into ~/.config/fish/functions/fish_prompt.fish, which will be
auto-loaded when Fish attempts to render the prompt.