Skip to content
Permalink
master
Switch branches/tags
Go to file
 
 
Cannot retrieve contributors at this time

Elvish Chain theme prompt

This is my implementation for Elvish of the Chain prompt theme, based on the original fish theme at https://github.com/oh-my-fish/theme-chain.

Ported to Elvish by Diego Zamboni <diego@zzamboni.org>.

images/screenshot.jpg

This file is written in literate programming style, to make it easy to explain. See chain.elv for the generated file.

Table of Contents

Use

To use this theme, first install the github.com/zzamboni/elvish-themes package using epm:

epm:install github.com/zzamboni/elvish-themes

You can do this interactively or from your ~/.elvish/rc.elv file. If you want to put this in your rc.elv so that the package is automatically installed when needed, and avoid having a message printed every time the shell starts, you can add the &silent-if-installed=$true option to the above command.

Add the following to you ~/.elvish/rc.elv file to load and configure the theme:

use github.com/zzamboni/elvish-themes/chain

Prompt theme configuration

If you want the prompt to be shown using bold fonts, set the following:

chain:bold-prompt = $true

The default Elvish prompt settings work fine, but if you want to fine tune them, check the Prompts documentation. For example, if you want to keep the prompt from appearing inverted when “stale” (this may happen if you use git segments and are in a large git repo), you could set $edit:prompt-stale-transform to an identity function:

edit:prompt-stale-transform = $all~

On the other hand, if you want the prompt to be grayed out when stale, you can do the following:

edit:prompt-stale-transform = { each [x]{ styled $x[text] "bright-black" } }

The prompt chains on both sides can be configured by assigning to theme:chain:prompt-segments and theme:chain:rprompt-segments, respectively. These variables must be arrays, and the given segments will be automatically linked by $theme:chain:glyph[chain]. Their default values are:

prompt-segments-defaults = [ su dir git-branch git-combined arrow ]
rprompt-segments-defaults = [ ]

Each element can be any of the following:

  • The name of one of the built-in segments. Available segments: arrow, timestamp, su, dir, session (a glyph with a unique color for the current session, based on its PID), git-branch, git-dirty, git-untracked, git-ahead, git-behind, git-staged, git-combined (which combines in a single segment all the other git status indicators), git-timestamp (timestamp of the last git commit);
  • A string or the output of styled, which will be displayed as-is;
  • A lambda, which will be called and its output displayed;
  • The output of a call to chain:segment <style> <strings>, which returns a “proper” segment, enclosed in square brackets and styled as requested.

Note: using any of the git- segments will automatically start gitstatusd (using the github.com/href/gitstatus library) the first time a prompt is shown. If you have git segments on both the left and right prompts, this can lead to a race condition in which two copies of gitstatusd are started in parallel. To avoid this, you can manually start gitstatusd after loading the theme, as follows:

use github.com/zzamboni/elvish-themes/chain
chain:gitstatus:start

You can customize the glyphs and styles used for the different segments by assigning to the corresponding elements of chain:glyph and chain:segment-style, respectively.

The value assigned to an element of chain:segment-style can be:

  • Any valid argument to styled.
  • The string default to indicate no special color (this will show in your regular terminal text color).
  • The string session to produce a color based on the current process ID, which can be used to indicate a unique color for the current session. This is used by the session segment, but can be assigned to any segment.

You can change the segment delimiters from the default square brackets by assigning a new value to $chain:prompt-segment-delimiters. Its value can be a two-character string (for single-character delimiters) or a two-element list (for multi-character or empty delimiters). Examples:

chain:prompt-segment-delimiters = "{}"          # {segment}
chain:prompt-segment-delimiters = "  "          #  segment
chain:prompt-segment-delimiters = [ "<<" ">>" ] # <<segment>>
chain:prompt-segment-delimiters = [ "" "" ]     # segment

By default, all segments are separated by the chain glyph. As a special case, you can specify whether the last segment should be separated by a chain or not by setting the $chain:show-last-chain variable to $false. This can be used to designate some special character in the arrow segment glyph (e.g. a Unicode arrow) and have it shown without a separator. For example:

chain:show-last-chain = $false
chain:glyph[arrow] = "→"

Also by default, a space is added after the arrow segment. You can supress this (for example, if the glyph you use for the arrow segment already includes sufficient spacing) by setting $chain:space-after-arrow to $false:

chain:space-after-arrow = $false

Git repo summary display

This module also includes the chain:summary-status function, which provides a status summary of git repositories, using the git-combined, git-branch and git-timestamp segments. The list is presented in reverse chronological order according to their latest git commit (only if your version of Elvish supports the order builtin). I use this to get a quick summary of the status of my most commonly-used repos. The repositories to display can be provided in mutliple ways (if more than one is specified, the first one found is used):

  • Default behavior when no arguments nor options are given: read from a JSON file specified in $chain:summary-repos-file (default value: ~/.elvish/package-data/elvish-themes/chain-summary-repos.json). The contents of this file can be manipulated using the chain:add-summary-repo and chain:remove-summary-repo functions (see example below).
  • As arguments to the function, e.g. chain:summary-status dir1 dir2.
  • All the git repos inside your home directory: chain:summary-status &all. Note: this uses the fd command by default, can be changed by storing the new function in $chain:find-all-user-repos. Default value:
    chain:find-all-user-repos = {
      fd -H -I -t d '^.git$' ~ | each $path:dir~
    }
        
  • In combination with any of the above, the &only-dirty option can be used to only display repositories which are not clean.

You can add or remove directories to the list by using the chain:add-summary-repo and chain:remove-summary-repo functions. By default these functions add/remove the current directory, but you can also specify others. Example:

[~]─> cd ~/.elvish
[~/.elvish]─[⎇ master]─> chain:add-summary-repo
Repo /Users/taazadi1/.elvish added to the list

[~/.elvish]─[⎇ master]─> chain:add-summary-repo ~/.emacs.d ~/.hammerspoon
Repo /Users/taazadi1/.emacs.d added to the list
Repo /Users/taazadi1/.hammerspoon added to the list

[~/.elvish]─[⎇ master]─> chain:summary-status
[2020-05-25] [OK] [⎇ master] ~/.elvish
[2020-05-27] [OK] [⎇ master] ~/.emacs.d
[2020-05-22] [OK] [⎇ master] ~/.hammerspoon

[~/.elvish]─[⎇ master]─> chain:summary-status ~/.elvish/lib/github.com/zzamboni/*
[2020-05-09] [OK] [⎇ master] ~/.elvish/lib/github.com/zzamboni/elvish-completions
[2020-05-08] [OK] [⎇ master] ~/.elvish/lib/github.com/zzamboni/elvish-modules
[2020-05-22] [●] [⎇ master] ~/.elvish/lib/github.com/zzamboni/elvish-themes

[~/.elvish]─[⎇ master]─> chain:summary-status &only-dirty ~/.elvish/lib/github.com/zzamboni/*
[2020-05-22] [●] [⎇ master] ~/.elvish/lib/github.com/zzamboni/elvish-themes

By default, a progress indicator is shown while the repository data is being collected. You can disable this by setting $chain:summary-progress-indicator to $false. The indicator characters to show can be customized by storing a string or list in $chain:summary-progress-steps.

Implementation

Base code and default values

Load the libraries we need.

use re
use str
use path

We use href’s gitstatus library for the git functions.

use github.com/href/elvish-gitstatus/gitstatus
use github.com/zzamboni/elvish-modules/spinners

Set up the default values for the chains (all can be configured by assigning to the appropriate variable):

prompt-segments = $prompt-segments-defaults
rprompt-segments = $rprompt-segments-defaults

Set up the default values for the glyphs used in the different chains. Note that some of the Unicode glyphs may need an extra space after them so that the character does not run into the next one in the terminal. This is highly dependent on the font you use, so please fine tune as needed. The default values work fine for the Inconsolata font I use.

default-glyph = [
  &git-branch=    "⎇"
  &git-dirty=     "●"
  &git-ahead=     "⬆"
  &git-behind=    "⬇"
  &git-staged=    "✔"
  &git-untracked= "+"
  &git-deleted=   "-"
  &su=            "⚡"
  &chain=         "─"
  &session=       "▪"
  &arrow=         ">"
]

Styling for each built-in segment. The value must be a valid argument to styled.

default-segment-style = [
  &git-branch=    [ blue         ]
  &git-dirty=     [ yellow       ]
  &git-ahead=     [ red          ]
  &git-behind=    [ red          ]
  &git-staged=    [ green        ]
  &git-untracked= [ red          ]
  &git-deleted=   [ red          ]
  &git-combined=  [ default      ]
  &git-timestamp= [ cyan         ]
  &git-repo=      [ blue         ]
  &su=            [ yellow       ]
  &chain=         [ default      ]
  &arrow=         [ green        ]
  &dir=           [ cyan         ]
  &session=       [ session      ]
  &timestamp=     [ bright-black ]
]

The $glyph and $segment-style maps are where the user can assign their custom glyphs or styles. Both are empty by default. If an element does not exist in these variables, the corresponding default value is used.

glyph = [&]
segment-style = [&]

To how many letters to abbreviate directories in the path - 0 to show in full.

prompt-pwd-dir-length = 1

Format to use for the timestamp segment, in strftime(3) format.

timestamp-format = "%R"

User ID that will trigger the su segment. Defaults to root (UID 0).

root-id = 0

Whether the prompt should be bold.

bold-prompt = $false

Whether the last segment should be separated by a chain or not. This can be used to designate some special character in the arrow segment and have it shown without a separator.

show-last-chain = $true

Whether a space should be added at the end of the arrow segment. Defaults to $true, but you may want to set it to $false depending on the characters you use for the arrow segment.

space-after-arrow = $true

The git-get-timestamp function gets executed to produce the text to be displayed in the git-timestamp module. You can change it if you want to change the format of what gets displayed.

git-get-timestamp = { git log -1 --date=short --pretty=format:%cd }

The prompt-segment-delimiters variable contains the “before” and “after” strings to be used in enclosing each prompt segment. By default each segment is enclosed in brackets. Note that for single-character delimiters you can use a two-character string like in the default value, but you could also use a list with two elements, in this case you can have multi-character delimiters, like in the commented-out example.

prompt-segment-delimiters = "[]"
# prompt-segment-delimiters = [ "<<" ">>" ]

General utility functions

Function to choose a color based on the current value of $pid, as an indicator of the current session.

fn -session-color {
  valid-colors = [ red green yellow blue magenta cyan white bright-black bright-red bright-green bright-yellow bright-blue bright-magenta bright-cyan bright-white ]
  put $valid-colors[(% $pid (count $valid-colors))]
}

Internal function to return a styled string, or plain if color is “default”. If $color is “session”, then a unique color is chosen for the current session using the -session-color function.

fn -colorized [what @color]{
  if (and (not-eq $color []) (eq (kind-of $color[0]) list)) {
    color = [(all $color[0])]
  }
  if (and (not-eq $color [default]) (not-eq $color [])) {
    if (eq $color [session]) {
      color = [(-session-color)]
    }
    if $bold-prompt {
      color = [ $@color bold ]
    }
    styled $what $@color
  } else {
    put $what
  }
}

We have two auxiliary functions to return the glyph or style corresponding to a given segment. Default values are stored in the module’s $default-glyph and $default-segment-style variables, but the user can provide their own values by setting $glyph and $segment-style respectively.

fn -glyph [segment-name]{
  if (has-key $glyph $segment-name) {
    put $glyph[$segment-name]
  } else {
    put $default-glyph[$segment-name]
  }
}
fn -segment-style [segment-name]{
  if (has-key $segment-style $segment-name) {
    put $segment-style[$segment-name]
  } else {
    put $default-segment-style[$segment-name]
  }
}

The -colorized-glyph returns the glyph for the given segment, with its corresponding style. If extra arguments are given, they are concatenated after the glyph.

fn -colorized-glyph [segment-name @extra-text]{
  -colorized (-glyph $segment-name)(str:join "" $extra-text) (-segment-style $segment-name)
}

Build a prompt segment in the given style, surrounded by square brackets. The first argument can be a style argument understood by styled, or the name of one of the predefined segments. In the latter case, the style is taken from the $segment-style map, and if a glyph for that segment name exists in the $glyph map, it is automatically prepended to the given text.

fn prompt-segment [segment-or-style @texts]{
  style = $segment-or-style
  if (or (has-key $default-segment-style $segment-or-style) (has-key $segment-style $segment-or-style)) {
    style = (-segment-style $segment-or-style)
  }
  if (or (has-key $default-glyph $segment-or-style) (has-key $glyph $segment-or-style)) {
    texts = [ (-glyph $segment-or-style) $@texts ]
  }
  text = $prompt-segment-delimiters[0](str:join ' ' $texts)$prompt-segment-delimiters[1]
  -colorized $text $style
}

Built-in Segment Definitions

This is where the built-in segments are defined. We assign the corresponding functions to elements of the $segment map, indexed by their segment name. The segment names need to correspond between the $segment, $glyph and $segment-style maps.

segment = [&]

git-related segments

Note that all the git-related segment functions only produce an output if the current directory contains a git repository.

We define a module-level variable which contains the latest git information. It gets populated once-per-prompt by the -parse-git function, and the information is used by all the segments.

last-status = [&]

The -parse-git function calls gitstatus:query to get the git status of the current directory. It extends the results with the result from -any-staged to have an easy indicator of staged files.

fn -parse-git [&with-timestamp=$false]{
  last-status = (gitstatus:query $pwd)
  if $with-timestamp {
    last-status[timestamp] = ($git-get-timestamp)
  }
}

The git-branch segment indicates the current branch name. If we are in a detached-branch state, we return the first 6 digits of the commit ID.

segment[git-branch] = {
  branch = $last-status[local-branch]
  if (not-eq $branch $nil) {
    if (eq $branch '') {
      branch = $last-status[commit][0..7]
    }
    prompt-segment git-branch $branch
  }
}

The git-timestamp segment shows the last-commit timestamp from the current branch.

segment[git-timestamp] = {
  ts = $nil
  if (has-key $last-status timestamp) {
    ts = $last-status[timestamp]
  } else {
    ts = ($git-get-timestamp)
  }
  prompt-segment git-timestamp $ts
}

The -show-git-indicator function takes a git segment name and returns whether it should be shown, depending on the information stored in $last-status. Since the git segment names do not correspond one-to-one with the elements of $last-status, we do here the mapping between them.

(note that for now, git-deleted is the same as git-dirty, since gitstatus does not report deleted files separately, only as unstaged changes)

fn -show-git-indicator [segment]{
  status-name = [
    &git-dirty=  unstaged        &git-staged=    staged
    &git-ahead=  commits-ahead   &git-untracked= untracked
    &git-behind= commits-behind  &git-deleted=   unstaged
  ]
  value = $last-status[$status-name[$segment]]
  # The indicator must show if the element is >0 or a non-empty list
  if (eq (kind-of $value) list) {
    not-eq $value []
  } else {
    and (not-eq $value $nil) (> $value 0)
  }
}

Generic function to display a git prompt segment.

fn -git-prompt-segment [segment]{
  if (-show-git-indicator $segment) {
    prompt-segment $segment
  }
}

We support the following git indicator segments:

(note that for now, git-deleted still exists but is the same as git-dirty, since gitstatus does not report deleted files separately, only as unstaged changes, so it’s removed from the default list above)

#-git-indicator-segments = [untracked deleted dirty staged ahead behind]
-git-indicator-segments = [untracked dirty staged ahead behind]
  • The git-dirty segment indicates whether there are any local modifications (modified or deleted files).
  • The git-ahead and git-behind segments indicate whether the current repository is ahead or behind of the upstream remote, if any.
  • The git-staged, git-untracked segments indicate whether there are staged-but-uncommited or untracked files, respectively.

Using -git-prompt-segment, we define all these git segments.

each [ind]{
  segment[git-$ind] = { -git-prompt-segment git-$ind }
} $-git-indicator-segments

The git-combined segment combines all the different status indicators in a single segment. The $segment-style[git-combined] value determines the color used for the surrounding brackets.

segment[git-combined] = {
  indicators = [(each [ind]{
        if (-show-git-indicator git-$ind) { -colorized-glyph git-$ind }
  } $-git-indicator-segments)]
  if (> (count $indicators) 0) {
    color = (-segment-style git-combined)
    put (-colorized $prompt-segment-delimiters[0] $color) $@indicators (-colorized $prompt-segment-delimiters[1] $color)
  }
}

dir

For this segment we also need a support function, which returns the current path with each directory name shortened to a maximum of $prompt-pwd-dir-length characters.

fn -prompt-pwd {
  tmp = (tilde-abbr $pwd)
  if (== $prompt-pwd-dir-length 0) {
    put $tmp
  } else {
    re:replace '(\.?[^/]{'$prompt-pwd-dir-length'})[^/]*/' '$1/' $tmp
  }
}
segment[dir] = {
  prompt-segment dir (-prompt-pwd)
}

su

This segment outputs a glyph if the current user has a privileged ID (root by default, with ID 0, but can be configured by changing $root-id). We precompute the UID since it cannot change in the middle of the session. This avoids calling the id command on every prompt.

uid = (id -u)
segment[su] = {
  if (eq $uid $root-id) {
    prompt-segment su
  }
}

timestamp

This segment simply outputs the current date according to the format defined in $timestamp-format.

segment[timestamp] = {
  prompt-segment timestamp (date +$timestamp-format)
}

session

This segment prints a session indicator in a color unique to the current session, based on its $pid.

segment[session] = {
  prompt-segment session
}

arrow

This segment prints the separator between the other chains and the cursor. If $chain:space-after-arrow is true (its default value), a space is appended at the end.

segment[arrow] = {
  end-text = ''
  if $space-after-arrow { end-text = ' ' }
  -colorized-glyph arrow $end-text
}

Chain- and prompt-building functions

Given a segment specification, return the appropriate value, depending on whether it’s the name of a built-in segment, a lambda, a string or a styled object.

fn -interpret-segment [seg]{
  k = (kind-of $seg)
  if (eq $k 'fn') {
    # If it's a lambda, run it
    $seg
  } elif (eq $k 'string') {
    if (has-key $segment $seg) {
      # If it's the name of a built-in segment, run its function
      $segment[$seg]
    } else {
      # If it's any other string, return it as-is
      put $seg
    }
  } elif (or (eq $k 'styled') (eq $k 'styled-text')) {
    # If it's a styled object, return it as-is
    put $seg
  } else {
    fail "Invalid segment of type "(kind-of $seg)": "(to-string $seg)". Must be fn, string or styled."
  }
}

Given a list of segments (which can be built-in segment names, lambdas, strings or styled objects), return the appropriate chain, including the chain connectors.

fn -build-chain [segments]{
  if (eq $segments []) {
    return
  }
  for seg $segments {
    if (str:has-prefix (to-string $seg) "git-") {
      -parse-git
      break
    }
  }
  first = $true
  output = ""
  for seg $segments {
    output = [(-interpret-segment $seg)]
    if (> (count $output) 0) {
      if (not $first) {
        if (or $show-last-chain (not-eq $seg $segments[-1])) {
          -colorized-glyph chain
        }
      }
      put $@output
      first = $false
    }
  }
}

Finally, we get to the functions that build the left and right prompts, respectively. These are basically wrappers around -build-chain with the corresponding arguments.

fn prompt {
  if (not-eq $prompt-segments []) {
    -build-chain $prompt-segments
  }
}

fn rprompt {
  if (not-eq $rprompt-segments []) {
    -build-chain $rprompt-segments
  }
}

Initialization

Default setup function, assigning our functions to edit:prompt and edit:rprompt

fn init {
  edit:prompt = $prompt~
  edit:rprompt = $rprompt~
}

We call the init function automatically on module load.

init

Bonus: displaying the status of several git repos at once

chain:summary-status provides a summarized list of the git-combined and git-branch indicators for a given set of repositories (I use this to check the status of repos on which I’m frequently working). The repositories to display can be provided in mutliple ways (if more than one is specified, the first one found is used):

  • As arguments to the function, e.g. chain:summary-status dir1 dir2.
  • All the git repos inside your home directory: chain:summary-status &all. Note: this uses the fd command by default, can be changed by storing the new function in $chain:find-all-user-repos. Default value:
    find-all-user-repos = {
      fd -H -I -t d '^.git$' ~ | each $path:dir~
    }
        
  • Read from a JSON file specified in $chain:summary-repos-file. Default value:
    summary-repos-file = ~/.elvish/package-data/elvish-themes/chain-summary-repos.json
        

    The contents of this file can be manipulated using the chain:add-summary-repo and chain:remove-summary-repo.

The list of repositories read from the file is cached in $chain:summary-repos.

summary-repos = []

We define a couple of functions to read and write $chain:summary-repos from disk.

fn -write-summary-repos {
  mkdir -p (path:dir $summary-repos-file)
  to-json [$summary-repos] > $summary-repos-file
}

fn -read-summary-repos {
  try {
    summary-repos = (from-json < $summary-repos-file)
  } except {
    summary-repos = []
  }
}

The chain:summary-data function collects the data from a given set of repositories.

fn summary-data [repos]{
  each [r]{
    try {
      cd $r
      -parse-git &with-timestamp
      status = [($segment[git-combined])]
      put [
        &repo= (tilde-abbr $r)
        &status= $status
        &ts= $last-status[timestamp]
        &timestamp= ($segment[git-timestamp])
        &branch= ($segment[git-branch])
      ]
    } except e {
      put [
        &repo= (tilde-abbr $r)
        &status= [(styled '['(to-string $e)']' red)]
        &ts= ""
        &timestamp= ""
        &branch= ""
      ]
    }
  } $repos
}

The chain:summary-status function is the main entry point to display the status of the configured repos.

fn summary-status [@repos &all=$false &only-dirty=$false]{
  prev = $pwd

  # Determine how to sort the output. This only happens in newer
  # versions of Elvish (where the order function exists)
  use builtin
  order-cmd~ = $all~
  if (has-key $builtin: order~) {
    order-cmd~ = { order &less-than=[a b]{ <s $a[ts] $b[ts] } &reverse }
  }

  # Read repo list from disk, cache in $chain:summary-repos
  -read-summary-repos

  # Determine the list of repos to display:
  # 1) If the &all option is given, find them
  if $all {
    spinners:run &title="Finding all git repos" &style=blue {
      repos = [($find-all-user-repos)]
    }
  }
  # 2) If repos is not given nor defined through &all, use $chain:summary-repos
  if (eq $repos []) {
    repos = $summary-repos
  }
  # 3) If repos is specified, just use it

  # Produce the output
  spinners:run &title="Gathering repo data" &style=blue { summary-data $repos } | order-cmd | each [r]{
    status-display = $r[status]
    if (or (not $only-dirty) (not-eq $status-display [])) {
      if (eq $status-display []) {
        status-display = [(-colorized "[" session) (styled OK green) (-colorized "]" session)]
      }
      @status = $r[timestamp] ' ' (all $status-display) ' ' $r[branch]
      echo &sep="" $@status ' ' (-colorized $r[repo] (-segment-style git-repo))
    }
  }
  cd $prev
}

The chain:add-summary-repo and chain:remove-summary-repo functions can be used to add/remove directories from the summary list. If no directories are given as arguments, they operate on the current directory.

fn add-summary-repo [@dirs]{
  if (eq $dirs []) {
    dirs = [ $pwd ]
  }
  -read-summary-repos
  each [d]{
    if (has-value $summary-repos $d) {
      echo (styled "Repo "$d" is already in the list" yellow)
    } else {
      summary-repos = [ $@summary-repos $d ]
      echo (styled "Repo "$d" added to the list" green)
    }
  } $dirs
  -write-summary-repos
}
fn remove-summary-repo [@dirs]{
  if (eq $dirs []) {
    dirs = [ $pwd ]
  }
  -read-summary-repos
  @new-repos = (each [d]{
      if (not (has-value $dirs $d)) { put $d }
  } $summary-repos)
  each [d]{
    if (has-value $summary-repos $d) {
      echo (styled "Repo "$d" removed from the list." green)
    } else {
      echo (styled "Repo "$d" was not on the list" yellow)
    }
  } $dirs

  summary-repos = $new-repos
  -write-summary-repos
}