Ne v kontakte Asocial programmer's blog

Custom transient prompt in Fish

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.png
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) ' '
end

function __prompt_rich --description "Extended prompt form for the current command"
    set -l normal (set_color normal)
    echo -s '╭─' (prompt_login) ' ' (set_color $fish_color_cwd) (prompt_pwd) $normal (fish_vcs_prompt) $normal
    echo -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
function fish_prompt --description 'Write out the prompt'
    if test "$TRANSIENT" = transient
        __prompt_compact
    else
        __prompt_rich
    end
end

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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 --function expand-abbr suppress-autosuggestion

    # If the command is syntactically valid (including empty command),
    # it may be executed, which requires replacing the prompt with transient.
    if commandline --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.
        if commandline --paging-mode && test -n "$(commandline)"
            commandline -f accept-autosuggestion
            return 0
        end

        # About to execute the command, flag for the transient mode.
        set --global TRANSIENT transient
        # Repaint the prompt, then execute the command.
        commandline --function repaint execute
        return 0
    end

    # 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:

leftovers.png
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
function fish_prompt --description 'Write out the prompt'
    if test "$TRANSIENT" = transient
        __prompt_compact
        echo -en \e\[0J # Clear from cursor to end of screen
        set --global TRANSIENT normal
        return 0
    else
        __prompt_rich
    end
end

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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.
    if test "$(commandline --current-buffer)" = ""
        commandline --function cancel-commandline
        return 0
    end

    # Command is not empty, so we'll redraw this in a transient form and create
    # a fresh and empty one.
    set --global TRANSIENT 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 --function repaint 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.
    if test "$(commandline --current-buffer)" != ""
        return 0
    end
    # Repaint the last prompt as transient and exit.
    set --global TRANSIENT transient
    commandline --function repaint exit
end

# 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:

result.png
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.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
function __prompt_compact --description "Minimal prompt form for scrollback"
    echo -n -s (set_color cyan) '$' (set_color normal) ' '
end

function __prompt_rich --description "Extended prompt form for the current command"
    set -l normal (set_color normal)
    echo -s '╭─' (prompt_login) ' ' (set_color $fish_color_cwd) (prompt_pwd) $normal (fish_vcs_prompt) $normal
    echo -n -s '╰─ $ '
end

function fish_prompt --description 'Write out the prompt'
    if test "$TRANSIENT" = transient
        __prompt_compact
        echo -en \e\[0J # Clear from cursor to end of screen
        set --global TRANSIENT normal
        return 0
    else
        __prompt_rich
    end
end

# 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 --function expand-abbr suppress-autosuggestion

    # If the command is syntactically valid (including empty command),
    # it may be executed, which requires replacing the prompt with transient.
    if commandline --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.
        if commandline --paging-mode && test -n "$(commandline)"
            commandline -f accept-autosuggestion
            return 0
        end

        # About to execute the command, flag for the transient mode.
        set --global TRANSIENT transient
        # Repaint the prompt, then execute the command.
        commandline --function repaint execute
        return 0
    end

    # The command won't be executed, so simply trigger the default behavior.
    commandline -f execute
end

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.
    if test "$(commandline --current-buffer)" = ""
        commandline --function cancel-commandline
        return 0
    end

    # Command is not empty, so we'll redraw this in a transient form and create
    # a fresh and empty one.
    set --global TRANSIENT 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 --function repaint cancel-commandline kill-inner-line repaint-mode repaint
end

function __transient_ctrl_d_execute
    # If the current command line is not empty we shouldn't exit.
    if test "$(commandline --current-buffer)" != ""
        return 0
    end
    # Repaint the last prompt as transient and exit.
    set --global TRANSIENT transient
    commandline --function repaint exit
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

# 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.